fix(core)!: owner-only commitMigration() with full integrity checks, and correct id-validation error class - #171
Merged
Conversation
…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
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.No scoped path to commit a per-record migration.
@haverstack/conformance-fixturespinsPOST /records/:id/migrate(client sends{ toTypeId, content }, server validates againsttoTypeId's schema), butScopedStackhad no method for it — onlyStackAdapter.commitMigration()(the raw primitive) andStack.migrateAll()(unscoped, bulk, driven by registeredMigrationfunctions). A server had no way to implement the endpoint without bypassingScopedStack's permission layer or hand-duplicating it. AddedStack.commitMigration()andScopedStack.commitMigration().Id-format validation threw the wrong error class.
error-bad-request-id-invalid-charset,error-bad-request-id-invalid-length, anderror-bad-request-id-reserved-prefixare pinned as400 bad_request(StackQueryError) — structurally malformed input, not a content-validation failure. ButvalidateRecordId()threwStackValidationError(422), with the exact message text the fixtures expect for the 400 case. Changed toStackQueryError; message text unchanged. Same reasoning already applied to malformed pagination cursors indecodeCursor().Commits
714c4091a67c8fcommitMigration()owner-only + its integrity checks67fe4b2ifVersion/If-MatchoncommitMigration()a316e65migrateAll()through the same checked pathAuthorization: owner-acting-alone
ScopedStack.commitMigration()refuses every requester but the owner acting alone, delegation included. No grant or record-levelwritesubstitutes for it.This matches the bulk path.
migrateAll()lives onStackand is deliberately absent fromStackClient, for the same reasongrant()/revoke()are — so the per-record verb carries the restriction the family-wide one already had.data-model.mdalready 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:That is the general shape:
commitMigration()replacescontentandtypeIdwholesale, 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 gatecreate()applies and every gateupdate()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
403otherwise.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.entityIdguard; each item below was reachable without it.didby migrating out of_entity/_appnor pick one up on the way in. Previously a migration could move an_entitycard onto another DID, whichupdate()refuses._entityis grantable andrequireOwnerForOwnerDid()guards only the owner's own did, so immutability was the rule doing the work.did; peridentity.md, ambiguity is all an impersonating card needs._attachment@1fileId/mimeType/sizeimmutability, value-wise rather than presence-wise since a full replacement re-sends every required field. RepointingfileIdis the sharpest — it is what the uploader clause reads._attachmentfamily, which stakes a fresh claim on a fileId exactly ascreate()does._groupis refused. A group'sadminroster entry is stamped bycreate(), and the adapter'scommitMigration()writestypeIdandcontentalone — migrating in would produce a group with an empty roster, manageable by nobody but the owner. Version-to-version within_groupstays open and carries the roster.migrateAll()shares this pathmigrateAll()wrote straight toadapter.commitMigration()and ran onlyvalidateContent(), so it skipped all of the above.a316e65extractscommitMigrationChecked(existing, …)— taking the record already in hand, so the batch pass pays no re-fetch — and routes both callers through it.That a
Migrationfunction is app code is not a trust boundary here: the app callingcommitMigration()is the same app that registered the function. AndregisterMigration()places no constraint onfrom/tosharing abaseId, so a registered path can itself cross type families — makingmigrateAll()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-MatchcommitMigration()now takesifVersionlike every other version-bumping mutation. This removes an exception rather than adding one:ExpectedVersionOptionsdescribes itself as "accepted by every mutation that bumps a record's version" andversioning.mdsaysifVersion"covers every mutation path", butcommitMigration()was the only mutating adapter method whose opts wereSnapshotOptionsalone — 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/ScopedStack→sqlite-shared(checkExpectedVersion()beforeBEGIN, matchingpatchContent()/restoreVersion(), since fts5 removal must precede the content update) →adapter-api(ifMatchthrough the existingrequest()helper) → the three adapter wrappers andMemoryAdapter.migrateAll()sends none, so bulk migration stays last-writer-wins.Wire compatibility is additive. The
commit-migrationconformance fixture sends noIf-Matchand does not pin its absence, so it is unaffected; bywire-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'scommitMigration()now reports a missing record up front rather than failing after the write with "Record not found after commitMigration".Spec
docs/spec/access-control.md—commitMigration()gets its own bullet: owner-acting-alone, why no grant substitutes, and the_attachment@1escalation 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 coverscommitMigration()'s authorization, the integrity checks, and thatmigrateAll()applies the same ones on the same path.docs/spec/wire-format.md— names the client-side entry point, states the owner-only/403rule, and addsPOST .../migrateto theIf-Matchlist.docs/spec/versioning.md— addscommitMigrationto the enumeration of methods acceptingifVersion.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
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-levelwriteare 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_entitycard onto another DID.Stack.commitMigration(): typeId+content change together, version-history snapshotting, schema validation againsttoTypeId, unknown-type/not-found, reserved content keys,_config.entityId._attachmentimmutability (repoint, and mimeType/size rewrite) with a pass-through, mimeType establishment from outside the family,_grouprefusal 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:Stacklevel (stale rejects and leaves typeId untouched, matching applies, nonexistent record isStackNotFoundError), sqlite adapter level (atomic, FTS index undisturbed on rejection), andadapter-apilevel (If-Matchsent when given, omitted when not).StackValidationErrortoStackQueryError.Notes for reviewers
Breaking relative to this PR's own earlier commits, not relative to
main—commitMigration()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 againstmainismigrateAll()'s widened abort conditions, described above.haverstack/serverisn't touched here — server#32's two left-undone items (the/migrateendpoint, 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,403otherwise, and may acceptIf-Match.