fix(metadata-protocol,metadata): revert reads the history row under the key the writer stored it with (#7559) - #7619
Conversation
…he key the writer stored it with (#7559) Commit-revert answered `VERSION_NOT_FOUND: No history row at version 2` over a row `GET .../history` lists, and `POST /packages/:id/revert` answered 500. Measured both sides of the same row, driving a real publish twice through the real protocol and SysMetadataRepository: writer sys_metadata_history.version = per-(org,type,name) lineage counter; drafts consume numbers, so publish #1 is v2 and the commit records prevVersion: 2 -- and every row lands at organization_id = NULL, because publish routes each draft to the draft's OWN scope (#3115). reader restoreVersion asks for version 2 -- agrees; does not filter package_id, and the history table has no such column -- agrees; scopes organization_id to the REQUEST's active org -- DISAGREES. organization_id alone is the disagreeing key. Not a regression of #6215: that one fails later, at restoreVersion's put() parent lookup, with a 409, and its package_id scoping is intact and uninvolved here. revertCommit and rollbackMetaItem now resolve the scope an item's lineage actually lives in (caller's own overlay first, env-wide second), per item for a batch. The resolved scope also reaches the #6602 registry heal and the #4636 package-binding read, which an org-scoped revert of an env-wide row was skipping while reporting success. Second half, a separate defect on the same feature: revertPackage threw bare Errors with no code/status, so errorFromThrown had nothing to classify and fell back to 500. Now RESOURCE_NOT_FOUND/404 and RESOURCE_CONFLICT/409 per ADR-0112. The route itself is UNCHANGED: its handler already wraps the whole body in one catch that calls errorFromThrown, so the per-route catch this card's first reading called for was inert -- reverse verification caught that, and it is not in this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155v8vapVCt98zb9eWVhbLq
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…BT ledger
`check-type-check-coverage --re-measure` went red: objectql TEST_DEBT
records 355, tsc reported 356 (+1). The three errors the new test file
contributed, all invisible to the package's own `typecheck` script
because that config excludes `*.test.ts` while the ratchet measures with
the tests put back:
TS2554 x3 registerObject(schema) -- `packageId` is a REQUIRED second
parameter, not optional
TS2345 x3 the `Record<string, unknown>` spread in the field helper
put an index signature on every field, so the object was
not assignable to ServiceObject
TS2322 x5 `longtext` is not in the FieldType union; the spelling is
`textarea`
(The last two surfaced only once the one before it was fixed, which is
why the count moved 356 -> 356 -> 358 -> 353 rather than straight down.)
Measured the way the gate measures -- a sibling tsconfig that extends
the package's own with the test globs dropped from `exclude`, over a
FULLY BUILT closure. Without the build the same command reports 654, the
TS2307-plus-implicit-any cascade the script itself refuses to record.
objectql now measures 353 against a recorded 355. Ledger deliberately
UNCHANGED: the -2 is the gate's informational "can be lowered" line, and
lowering it -- or any other entry's -- is not this PR's business.
Behaviour unaffected: objectql 180 files / 3194 tests green, and reverse
verification still arms -- reverting the protocol fix under the new
fixtures still turns exactly the two positive-identity cases red with
VERSION_NOT_FOUND.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v8vapVCt98zb9eWVhbLq
Fixes #7559
The card deliberately stopped at "the revert's lookup key disagrees with what the history writer stores" and asked for a measurement before a patch. This is that measurement, then the fix it selects.
The measurement — both sides of the same row
Driven against a real
ObjectQLengine and the realSysMetadataRepositorybehind the protocol: author an env-wide draft, publish the package, edit, publish again — with the publish and revert requests carrying an active organization, which is what every console request carries (resolveActiveOrganizationIdputs one on all of them). Env-wide is what Studio / AI authoring writes, andSysMetadataRepository.listDraftsdeliberately surfaces those drafts to a non-null-org caller through its$or.Rows after the second publish:
sys_metadata_history.version, the per-(org,type,name)lineage counter. Drafts consume numbers too, so publish #1 is v2 and the commit recordsprevVersion: 2restoreVersion(targetVersion)with exactly 2package_id/ overlaysys_metadata_historyhas nopackage_idcolumn at allprevious_checksumon the history roworganization_idNULL— publish routes each draft to the draft's own scope, and capturesprevVersionfrom the row in that scope (the #3115 rule)'org_x'—getOverlayRepo(request.organizationId)So the reader asks
sys_metadata_historyfor(organization_id='org_x', type='view', name='cases', version=2), matches nothing, and answers:— the card's symptom verbatim, version number included. Two controls isolate the key to
organization_idalone:success: true, restored.success: true, restored.historyMetaItemshows the same split from the read side: with no org it lists all four versions; withorganizationId: 'org_x'it returns[]. That is consistent with the card's "/historylists exactly that row" — the listing the QA read was not scoped to the org the revert was.The sibling item-level revert has the identical defect, measured the same way and not previously reported:
rollbackMetaItem({ type:'view', name:'cases', toVersion:2, organizationId:'org_x' })threwVERSION_NOT_FOUND/ 404 while the same call without the org succeeded.Not a regression of #6215 — confirmed, as asked
Confirmed against the code, not from symptoms. #6215 scoped
restoreVersion'sput()parent lookup bypackage_id; its fix is present and intact (restoreVersionreads the raw active row and threadsactivePackageIdintoput). It is also structurally uninvolved here: this failure happens one step earlier, in the history version lookup, and that table carries nopackage_idcolumn for the scoping to apply to. Different key (organization_id), different stage (before the row is located rather than after), different class (404VERSION_NOT_FOUNDrather than 409). Same family — the revert path's row addressing disagreeing with the writer's — as the card predicted.The fix
One shared resolver,
resolveMetaItemOrgScope, applied at both revert callers: resolve the scope the item's lineage actually lives in rather than assuming the caller's active org. Precedence is the ADR-0005 overlay order — the caller's own overlay first, env-wide second, and the caller's own scope unchanged when neither has a lineage, so a genuinely absent item still fails in the scope the caller asked about. Nocatch: a driver failure fails the revert rather than resolving to a scope nobody verified.This is the read-side half of the rule the write side already states and
publishPackageDraftsalready follows.revertCommitresolves per item, because a batch legitimately mixes an env-wide artifact with an org overlay and a hoisted scope has to pick one and be wrong about the other.Two latent bugs fall out with it: the
#6602registry heal and the#4636package-binding read both received the request's org, so an org-scoped revert of an env-wide row skipped the heal while reporting success. The#6602call site's own comment already said "the row's OWN scope, per item" while passing the request's org; the resolution is what makes that comment true.Second half — the package-level revert 500
The route is unchanged, and that is the finding. The first reading of this card is that
POST /packages/:id/revertneeds its owncatch. It does not:handlePackagesRequestwraps its entire body in onetry { … } catch (e) { errorFromThrown(e, 500) }, so the throw was always classified.errorFromThrownreadsstatusandcodeoff the error and falls back to 500 only when it finds neither — andMetadataManager.revertPackagethrew bareErrors carrying neither, for two perfectly ordinary refusals. The whole defect is the thrown shape.I wrote the per-route
catchfirst and reverse verification showed it inert — the envelope cases pass with the manager fixed and the route untouched — so it is not in this PR. The route file is byte-identical tomain.Both refusals now carry a declared envelope (ADR-0112): unknown package id →
RESOURCE_NOT_FOUND/ 404; never-published package →RESOURCE_CONFLICT/ 409. Both come from the ADR-0112 standard catalog rather than the extension ledger, per the ledger's own rule that a generic condition (not found / conflict) uses the standard catalog instead of registering a synonym — so no ledger edit and no spec regeneration.Not filed as a separate issue: it is on the same feature and inside this card's reach, which the dispatch asked for in preference to filing.
Tests
Positive identity is pinned first and asserts the restored body, not merely the absence of an error — a revert that "succeeds" while restoring the wrong version is one step away from this defect. Refusal cases assert
codeandstatus; a barerejects.toThrow()is green against an implementation that throws a nakedError.New
packages/objectql/src/protocol-revert-org-scope.test.ts(7 cases) — real engine, real repository, full publish→revert round trip. The existing ADR-0067 suite stubsrepo.restoreVersionoutright, so it pins the revert plan and is structurally unable to see whether the number in that plan resolves to a row; #7559 lived exactly in that gap.revertCommitrestores the pre-commit body for an env-wide item with an active orgrollbackMetaItemrestores the same item for an org callerVERSION_NOT_FOUND/ 404revertCommitreports a genuinely missing version per item, carrying the codeCOMMIT_NOT_FOUND/ 404Also strengthened: the two
revertPackagecases inmetadata-service.test.ts(wererejects.toThrow(message), now assertcode+status+ message), and a new dispatcher case pinning that a declared refusal survives to the wire as its own status and code rather than being flattened to 500.Reverse verification — direction predicted before running
Half 1,
protocol.tsreverted tomain: predicted 2 red / 5 green; got exactly that. The two positive-identity cases failed withVERSION_NOT_FOUND. The writer-measurement case, the org-scoped control and the three refusals stayed green by design — they do not depend on the fix, and saying so is the point: they are what stops the suite passing by refusing everything.Half 2, manager reverted to
main: 2 red (the envelope cases). The route case stayed green — the unpredicted direction that found the inert route change, reported above rather than papered over.Local runs
@objectstack/metadataand@objectstack/metadata-protocoldeclare notypecheckscript (ledger DEBT), so the typecheck above is the full coverage available for the four packages, not a subset I chose. Suites were run afterpnpm --filter 'PKG^...' buildin a fresh worktree; the gate farm beyond the four families named above is CI's.Generated by Claude Code