Skip to content

Refuse deletes that would remove a space's identity entities - #111

Open
0xTaneja wants to merge 1 commit into
geobrowser:mainfrom
0xTaneja:feat/protect-space-anchor-entities
Open

0xTaneja wants to merge 1 commit into
geobrowser:mainfrom
0xTaneja:feat/protect-space-anchor-entities

Conversation

@0xTaneja

Copy link
Copy Markdown
Contributor

Follow-up to the personal-space wipe of many curators . This implements the SDK-level guard from the deletion-safety recommendations.

Why this belongs in the SDK

The incident deleted nothing through a skill. It ran a hand-authored TypeScript loop calling the delete path directly, in a different repo. Every skill-level protection - anchored-entity checks, dry-run expiry, the publishOps circuit-breaker - was bypassed because the caller read the docs and then wrote its own version without them.

An agent that freelances its own loop bypasses 100% of caller-side protection, so the invariant has to live where the ops are built.

What it does

geo.entities.delete(...) and the deprecated Graph.deleteEntity(...) now throw ProtectedEntityError when the target is a space's anchored entity: its page/home entity, or the Avatar or Cover image that entity points at.

Refusing to delete ba886bf1…: it is the home entity of space c45e5508…, which holds the space's name, description and identity. Pass `deleteAnchored: true` to delete it anyway.

These are the entities a caller iterating a space cannot tell apart from content - they're ordinary Image entities and one Page entity sitting in the same result set as everything else. That's why one stray loop takes the space's identity with it.

API

// Refused by default
await geo.entities.delete({ id: spacePageId, spaceId });

// Deliberate
await geo.entities.delete({ id: spaceAvatarId, spaceId, deleteAnchored: true });

ProtectedEntityError carries entityId, spaceId, and a reason of 'page' | 'avatar' | 'cover', so a bulk caller can skip an anchor and keep going rather than aborting at entity 400 of 11,000:

if (!(error instanceof ProtectedEntityError)) throw error;
console.warn(`skipped ${error.entityId} (${error.reason})`);

anchoredEntityIds() and spaceAnchorsQueryField() are exported too, so a
caller can resolve the anchor set up front and filter a work list before
deleting anything.

No extra round-trip

The anchors resolve inside the query deleteEntity already makes. This matters: the failure mode is a loop over thousands of entities, and a guard that doubled the request count is a guard people switch off.

Testing

  • 34 new tests (protected-entities.test.ts, plus a block in delete-entity.test.ts), 579 total passing, biome and tsc clean.
  • Verified against the live testnet API — ops built, nothing published:
target result
personal space page entity REFUSED
personal space avatar image REFUSED
DAO space cover image REFUSED
page entity + deleteAnchored: true ALLOWED, 17 ops
ordinary news story ALLOWED, 7 ops
page id passed dashed REFUSED

What this does not cover

Recommendation 3 - the bulk-destruction threshold. deleteEntity sees one entity at a time, so it structurally cannot know it's the 400th call in a runaway loop. That check belongs where the full ops array is visible (personalSpaces.publishEdit / daoSpaces.proposeEdit), mirroring the publishOps >50-destructive circuit-breaker. Separate PR, happy to open it.

`geo.entities.delete(...)` and the deprecated `Graph.deleteEntity(...)` now
throw `ProtectedEntityError` when the target is the space's `page`/home entity
or one of the Avatar / Cover images that entity points at.

Those entities carry the space itself rather than content in it, and a caller
iterating a space's entities cannot tell them apart from anything else it is
deleting, so one stray loop takes the space's name, description and profile
image with it. Skill-level guards do not help here: an agent that writes its
own delete loop bypasses them entirely, so the invariant belongs where the ops
are built.

Anchors resolve inside the request `deleteEntity` already makes, so the guard
costs no extra round-trip on a path that is called once per entity. The error
carries `entityId`, `spaceId` and a `reason` of 'page' | 'avatar' | 'cover' so
a bulk caller can skip anchored entities instead of aborting. Pass
`deleteAnchored: true` to delete one deliberately.

`ProtectedEntityError`, `anchoredEntityIds()` and `spaceAnchorsQueryField()`
are exported so callers can filter a work list before deleting anything.

BEHAVIOR CHANGE: a call that previously deleted one of these entities now
throws until `deleteAnchored: true` is added.
@0xTaneja
0xTaneja requested a review from nikgraf August 26, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant