Skip to content

fix(core)!: owner-only commitMigration() with full integrity checks, and correct id-validation error class - #171

Merged
cuibonobo merged 4 commits into
mainfrom
claude/core-conformance-gaps-gr3c85
Aug 18, 2026
Merged

fix(core)!: owner-only commitMigration() with full integrity checks, and correct id-validation error class#171
cuibonobo merged 4 commits into
mainfrom
claude/core-conformance-gaps-gr3c85

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Aug 17, 2026

Copy link
Copy Markdown
Member

Summary

Two conformance gaps between core and @haverstack/conformance-fixtures@0.1.0, found while implementing haverstack/server#32 (haverstack/server#64), plus the two consistency fixes that fell out of reviewing the first.

  1. No scoped path to commit a per-record migration. @haverstack/conformance-fixtures pins POST /records/:id/migrate (client sends { toTypeId, content }, server validates against toTypeId's schema), but ScopedStack had no method for it — only StackAdapter.commitMigration() (the raw primitive) and Stack.migrateAll() (unscoped, bulk, driven by registered Migration functions). A server had no way to implement the endpoint without bypassing ScopedStack's permission layer or hand-duplicating it. Added Stack.commitMigration() and ScopedStack.commitMigration().

  2. Id-format validation threw the wrong error class. error-bad-request-id-invalid-charset, error-bad-request-id-invalid-length, and error-bad-request-id-reserved-prefix are pinned as 400 bad_request (StackQueryError) — structurally malformed input, not a content-validation failure. But validateRecordId() threw StackValidationError (422), with the exact message text the fixtures expect for the 400 case. Changed to StackQueryError; message text unchanged. Same reasoning already applied to malformed pagination cursors in decodeCursor().

Commits

714c409 The two conformance fixes above
1a67c8f commitMigration() owner-only + its integrity checks
67fe4b2 ifVersion/If-Match on commitMigration()
a316e65 Route migrateAll() through the same checked path

Authorization: owner-acting-alone

ScopedStack.commitMigration() refuses every requester but the owner acting alone, delegation included. No grant or record-level write substitutes for it.

This matches the bulk path. migrateAll() lives on Stack and is deliberately absent from StackClient, for the same reason grant()/revoke() are — so the per-record verb carries the restriction the family-wide one already had. data-model.md already described migration as "explicit and owner-driven".

The first revision of this PR gated it on update authority over the record plus create authority on toTypeId. That was reachable as a privilege escalation, and the escalation is the reason for the narrower gate:

_attachment@1 is a grantable type. A requester holding a create grant on it, plus write access to any record they authored, could migrate that record into the family naming any fileId — then read the bytes via canAccessFile()'s uploader clause, which matches on an _attachment@1 record's entityId and content.fileId. ScopedStack.create() refuses exactly this (hasReadableReference(), see attachments.md § Creating _attachment@1 records directly); migrate reached the same state without applying it.

That is the general shape: commitMigration() replaces content and typeId wholesale, so it is create-shaped at the destination and update-shaped over the record as it stands. A grant-based version has to re-derive every gate create() applies and every gate update() applies, and reopens each one it misses. Ordinary write access to a record is not consent to move it between families.

A server implementing the endpoint serves it to the stack owner and answers 403 otherwise.

Integrity checks on the migration write

Enforced in Stack, so they hold for embedded use and the server's own code alike. The first revision carried over only schema validation, the reserved-key check and the _config.entityId guard; each item below was reachable without it.

  • DID binding immutability, across the union of the source and destination families' binding fields — a card can neither shed its did by migrating out of _entity/_app nor pick one up on the way in. Previously a migration could move an _entity card onto another DID, which update() refuses. _entity is grantable and requireOwnerForOwnerDid() guards only the owner's own did, so immutability was the rule doing the work.
  • DID binding uniqueness in the destination family, excluding the record itself. Previously two cards could claim one did; per identity.md, ambiguity is all an impersonating card needs.
  • _attachment@1 fileId/mimeType/size immutability, value-wise rather than presence-wise since a full replacement re-sends every required field. Repointing fileId is the sharpest — it is what the uploader clause reads.
  • The mimeType-establishment check for a record arriving from outside the _attachment family, which stakes a fresh claim on a fileId exactly as create() does.
  • Migrating into _group is refused. A group's admin roster entry is stamped by create(), and the adapter's commitMigration() writes typeId and content alone — migrating in would produce a group with an empty roster, manageable by nobody but the owner. Version-to-version within _group stays open and carries the roster.

migrateAll() shares this path

migrateAll() wrote straight to adapter.commitMigration() and ran only validateContent(), so it skipped all of the above. a316e65 extracts commitMigrationChecked(existing, …) — taking the record already in hand, so the batch pass pays no re-fetch — and routes both callers through it.

That a Migration function is app code is not a trust boundary here: the app calling commitMigration() is the same app that registered the function. And registerMigration() places no constraint on from/to sharing a baseId, so a registered path can itself cross type families — making migrateAll() an unguarded family-crossing write path, not merely a narrower one.

Behavior change: a migration function that would move a DID binding, produce a duplicate binding, repoint an attachment, or emit a reserved content key now aborts the pass. That is the intended outcome, but it is the one change here that could break an existing app's migration function. Abort-on-first-failure semantics and "anything already committed stays committed" are unchanged — there are simply more conditions that can abort. The pre-loop "target type is not defined" check still runs first, so an undefined target still surfaces as StackMigrationError.

ifVersion / If-Match

commitMigration() now takes ifVersion like every other version-bumping mutation. This removes an exception rather than adding one: ExpectedVersionOptions describes itself as "accepted by every mutation that bumps a record's version" and versioning.md says ifVersion "covers every mutation path", but commitMigration() was the only mutating adapter method whose opts were SnapshotOptions alone — while being the only full-content-replacement write, i.e. the one where two racing writers lose the most.

Threaded as every sibling verb threads it: adapter contract → Stack/ScopedStacksqlite-shared (checkExpectedVersion() before BEGIN, matching patchContent()/restoreVersion(), since fts5 removal must precede the content update) → adapter-api (ifMatch through the existing request() helper) → the three adapter wrappers and MemoryAdapter. migrateAll() sends none, so bulk migration stays last-writer-wins.

Wire compatibility is additive. The commit-migration conformance fixture sends no If-Match and does not pin its absence, so it is unaffected; by wire-format.md's own negotiation rule an optional new request header is a minor change, never a major one.

As a side effect, sqlite-shared's commitMigration() now reports a missing record up front rather than failing after the write with "Record not found after commitMigration".

Spec

  • docs/spec/access-control.mdcommitMigration() gets its own bullet: owner-acting-alone, why no grant substitutes, and the _attachment@1 escalation motivating it. Removed from the _grant-record write-fence enumeration, which it no longer needs (the owner gate is strictly stronger).
  • docs/spec/data-model.md — § Type migrations covers commitMigration()'s authorization, the integrity checks, and that migrateAll() applies the same ones on the same path.
  • docs/spec/wire-format.md — names the client-side entry point, states the owner-only/403 rule, and adds POST .../migrate to the If-Match list.
  • docs/spec/versioning.md — adds commitMigration to the enumeration of methods accepting ifVersion.

The id-validation change needed no spec update — data-model.md § Record IDs already documented those violations as → 400; only the runtime was out of sync.

Verification

pnpm run format:check && pnpm run lint && pnpm test && pnpm run build && pnpm run typecheck

All green across the workspace (1108 tests), including adapter-api's conformance suite.

  • ScopedStack.commitMigration(): owner succeeds; anonymous, a delegated owner principal, an update grant, update+create grants together, a both-actions grant, and record-level write are each refused. Escalation regressions: a grantee cannot reach attachment bytes by migrating into _attachment@1 (asserting the bytes stay unreachable before and after), and cannot move an _entity card onto another DID.
  • Stack.commitMigration(): typeId+content change together, version-history snapshotting, schema validation against toTypeId, unknown-type/not-found, reserved content keys, _config.entityId.
  • Integrity: binding immutability (move, and shed-by-migrating-out), binding uniqueness, same-did pass-through, _attachment immutability (repoint, and mimeType/size rewrite) with a pass-through, mimeType establishment from outside the family, _group refusal with a version-to-version pass-through asserting the roster survives.
  • migrateAll(): aborts on a binding-moving migration function and on a reserved-key one, and still carries an unchanged binding through.
  • ifVersion: Stack level (stale rejects and leaves typeId untouched, matching applies, nonexistent record is StackNotFoundError), sqlite adapter level (atomic, FTS index undisturbed on rejection), and adapter-api level (If-Match sent when given, omitted when not).
  • Id-validation tests updated from StackValidationError to StackQueryError.

Notes for reviewers

Breaking relative to this PR's own earlier commits, not relative to maincommitMigration() is new here, so no released behavior changes. The ! markers flag where a later commit revises what an earlier one in this PR proposed. The one genuine behavior change against main is migrateAll()'s widened abort conditions, described above.

haverstack/server isn't touched here — server#32's two left-undone items (the /migrate endpoint, and the three id-validation tests currently pinned to 422 with a skew note) get picked up once a new core version publishes. The endpoint should be implemented as owner-only, 403 otherwise, and may accept If-Match.

claude added 2 commits August 17, 2026 14:17
…lass

Two conformance gaps between core and @haverstack/conformance-fixtures,
found while implementing haverstack/server#32:

- ScopedStack (and Stack) had no permission-checked path for a per-record
  migration, so a server backing POST /records/:id/migrate had to bypass
  ScopedStack entirely or hand-duplicate its permission logic. Add
  Stack.commitMigration()/ScopedStack.commitMigration(), mirroring how
  update()/restoreVersion() wrap the adapter: write access and ownership
  via requireUpdatable() (refusing a non-owner write to a _grant record),
  create authority on toTypeId (closing the same family-crossing
  escalation create() is already closed against — otherwise a write-holder
  could migrate any record into _app/_config/_grant without ever holding a
  create grant there), did/appId and owner-did protection, file-ref
  gating, and content validated against toTypeId's schema.

- Record id-format/reserved-prefix validation (Stack.create()/
  ScopedStack.create()) threw StackValidationError (422) instead of
  StackQueryError (400), disagreeing with conformance-fixtures' pinned
  400 expectation for structurally malformed ids — the same reasoning
  that already makes a malformed pagination cursor a StackQueryError.
  Message text is unchanged; only the error class/code moves.

Updates access-control.md, data-model.md, and wire-format.md to describe
the new method and its create-grant requirement.
…hecks

Follow-up to the review of #171. commitMigration() writes a full content
replacement under a new typeId, so it is create-shaped at the destination
and update-shaped over the record as it stands — but it inherited only
create()'s schema validation, reserved-key check and the _config guard.
Every other gate the sibling write paths apply was absent, making migrate
a second, unguarded route to state create()/update() refuse to reach.

ScopedStack.commitMigration() is now owner-acting-alone, replacing the
update-grant + create-grant model. This matches the bulk path: migrateAll()
lives on Stack and is deliberately absent from StackClient, for the same
reason grant()/revoke() are, so the per-record verb now carries the
restriction the family-wide one already had.

The grant-based version was reachable as a privilege escalation. Holding a
create grant on _attachment@1 (a grantable type) plus write access to any
record they authored, a requester could migrate that record into the
family naming any fileId, then read the bytes through canAccessFile()'s
uploader clause — the escalation create()'s non-owner _attachment@1
carve-out exists to refuse, reached by a path that did not apply it.

Stack.commitMigration() gains the integrity checks it owed regardless of
caller, since Stack is also reachable directly:

- DID binding immutability across the union of the source and destination
  families' binding fields, so a card can neither shed its did by
  migrating out of _entity/_app nor pick one up on the way in. Previously
  a migration could move an _entity card onto another DID, which update()
  refuses via checkBindingImmutable().
- DID binding uniqueness in the destination family, excluding the record
  itself. Previously two cards could end up claiming one did, which
  create()/update() refuse with StackConflictError.
- _attachment@1 fileId/mimeType/size immutability, asked value-wise rather
  than presence-wise since a full replacement necessarily re-sends every
  required field. Repointing fileId was the sharpest of these.
- The mimeType-establishment check for a record arriving from outside the
  _attachment family, matching create().
- Migrating into _group is refused: a group's admin roster entry is
  stamped at creation and the adapter's commitMigration() writes typeId
  and content alone, so it would produce a group nobody but the owner can
  manage. Version-to-version migration within _group stays open and
  carries the existing roster.

The id-validation change from the previous commit is unaffected.

Specs updated to match: access-control.md replaces the create-grant bullet
with the owner-only rule and its rationale, data-model.md documents the
integrity checks, wire-format.md states that a server serves
POST /records/:id/migrate to the owner and answers 403 otherwise, and
notes the endpoint's absence from the If-Match list.

Claude-Session: https://claude.ai/code/session_01JpQomi5W2T9zzS29wAaJFq
@cuibonobo cuibonobo changed the title fix(core): scoped commitMigration() and correct id-validation error class fix(core)!: owner-only commitMigration() with full integrity checks, and correct id-validation error class Aug 17, 2026
claude added 2 commits August 17, 2026 20:40
…istency

ExpectedVersionOptions describes itself as "accepted by every mutation that
bumps a record's version", and versioning.md says ifVersion "covers every
mutation path per the one-rule versioning model". Both were false in exactly
one place: commitMigration() bumps version and was the only mutating adapter
method whose opts were SnapshotOptions alone.

Migrate is also the write that most needs the fence — it replaces content
wholesale rather than merge-patching it, so two racing writers lose strictly
more than they do on update(). Treating it as the one exception left the
API's only full-replacement write with no concurrency control.

Threaded the way every sibling verb already threads it:

- types.ts: adapter commitMigration() opts -> ExpectedVersionOptions &
  SnapshotOptions.
- stack.ts: Stack/ScopedStack commitMigration() take IfVersionOptions;
  checkIfVersion() up front, expectedVersion passed to the adapter so the
  real check stays atomic inside the write. Added to the StackClient
  interface signature.
- sqlite-shared: checkExpectedVersion() before BEGIN, matching
  patchContent()/restoreVersion() — fts5 removal has to precede the content
  update, so the precondition can't fold into the UPDATE's WHERE clause.
  Also gives commitMigration() a proper not-found error instead of failing
  after the write with "Record not found after commitMigration".
- adapter-api: passes ifMatch through the existing request() helper.
- record-adapter-sqlite, adapter-local, MemoryAdapter: opts widened to match
  the adapter contract.

migrateAll() sends no ifVersion — a batch pass doesn't know each record's
version going in, so bulk migration stays last-writer-wins.

Wire compatibility: additive. The commit-migration conformance fixture sends
no If-Match and does not pin its absence, so it is unaffected; by
wire-format.md's own negotiation rule an optional new request header is a
minor change, never a major one.

Specs: wire-format.md adds POST .../migrate to the If-Match list and drops
the sentence declaring it unconditional; versioning.md adds commitMigration
to the enumeration of methods accepting ifVersion.

Claude-Session: https://claude.ai/code/session_01JpQomi5W2T9zzS29wAaJFq
…d path

migrateAll() wrote straight to adapter.commitMigration() and ran only
validateContent(), so it skipped every integrity check the previous commit
added to Stack.commitMigration() — reserved keys, content size, DID binding
immutability and uniqueness, the _attachment immutability and
mimeType-establishment checks, the _group refusal, and the _config guard.

That a Migration function is app code is not a trust boundary here: the app
calling commitMigration() is the same app that registered the function, and
neither is entitled to move a DID binding or repoint an attachment.
registerMigration() also places no constraint on `from` and `to` sharing a
baseId, so a registered path can itself cross type families — which is what
these checks are about. migrateAll() was therefore an unguarded
family-crossing write path, not merely a narrower one.

Extracted commitMigrationChecked(existing, toTypeId, content, ifVersion?),
taking the record already in hand rather than an id, so the batch pass does
not pay a re-fetch per record. commitMigration() is now getRecord +
not-found + checkIfVersion + the shared path; migrateAll()'s loop calls the
shared path directly, passing no ifVersion.

Behavior changes for migrateAll():
- A migration function that would move an _entity/_app DID binding, produce
  a duplicate binding, repoint an _attachment, or emit a reserved content
  key now aborts the pass. This is the one change that can break an existing
  app's migration function, and it is the intended outcome — those are the
  writes create()/update() already refuse.
- Abort-on-first-failure and "anything already committed earlier in the pass
  stays committed" are unchanged; there are simply more conditions that can
  abort a pass.
- The pre-loop "target type is not defined" check still runs first, so an
  undefined target still surfaces as StackMigrationError rather than the
  shared path's generic unknown-type error.

Cost is negligible for ordinary app types: uniqueBindingFieldsOf() is empty
outside _entity/_app, so most families add only a reserved-key scan and a
size check per record.

Spec: data-model.md § Type migrations now states that migrateAll() applies
the same checks on the same shared path, and why app code is not a trust
boundary for them.

Claude-Session: https://claude.ai/code/session_01JpQomi5W2T9zzS29wAaJFq
@cuibonobo
cuibonobo merged commit 1e20c14 into main Aug 18, 2026
5 checks passed
@cuibonobo
cuibonobo deleted the claude/core-conformance-gaps-gr3c85 branch August 18, 2026 12:46
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.

2 participants