fix(metadata-protocol): let an org-scoped caller revert an env-wide commit (#7819 tier 1) - #7857
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…ommit (#7819 tier 1) `revertCommit` and `rollbackToPackageCommit`'s target lookup each resolved their target commit with a strict `organization_id` equality, which matches no row whose column is NULL. An org-scoped caller therefore got COMMIT_NOT_FOUND (404) for any commit recorded env-wide — a row that demonstrably exists and that the same caller's `listCommits` hands back. Both lookups now accept org-scoped or env-wide rows, the same `$or` `deletePackage` (#7705) and `listCommits` (#7779) already carry. The `$or` was chosen over the two alternatives rather than copied. `where` is keyed on `id`, so the predicate reads like an authorization filter on a unique key; measured against the only door it is not one. Authorization is `requireManageMetadata`, checked before the call, and the `organizationId` that arrives is the session's active org selection from `resolveActiveOrganizationId` — a resolver whose body is entirely catch-wrapped and whose `undefined` omits the predicate, i.e. the widest reading. A boundary that fails open is not a boundary, which rules out "keep the check but distinguish 'not yours' from 'no such commit'". Dropping the predicate outright would newly let an org caller revert another organization's commit by id, a widening this card never asked for. The body already agreed with the `$or`: #7559 made each item resolve its scope from the row, and since #7814 `rollbackToPackageCommit` plans from `listCommits` (org + env-wide) and fed each id back into a lookup that refused half of them. The no-org branch is deliberately left un-narrowed, exactly as #7705 and #7779 left theirs. Pinned by a new real-engine/real-driver suite in packages/runtime (eight cases: the premise out of SQLite, the positive per site, both negative directions, and the no-org door per site; refusals asserted on code AND status per ADR-0112). The #7814 handoff assertion that pinned this as known-incomplete now asserts the rollback succeeds. Reverse verification, direction predicted first: 3 failed | 11 passed, exactly the three positive cases. Tier 1 only — `duplicatePackage` and `reassignOrphanedMetadata` are untouched and #7819 stays open to carry them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hxiiv8qFCUmDThHU1k7HV
… tier 1, part of #7620) CI red on PR #7857: `packages/objectql/src/protocol-commit-history.test.ts` had two org-scoped revert cases fail with COMMIT_NOT_FOUND. Measured rather than assumed: its `matchesWhere` was pure flat equality, so the widened lookup `{ id, $or: [{organization_id: <org>}, {organization_id: null}] }` compared `row['$or']` against the array and matched nothing. The double is the blind party, not the fix. Both failing rows carry the CALLER'S OWN org (`organization_id: 'org_a'`, request org `'org_a'`), so they match the FIRST `$or` branch outright — the same row the strict equality already accepted. No real behaviour changed, and neither case's subject (#6602's registry org-asymmetry) involves the commit lookup at all; it is merely the door they enter through. Conjoined with the sibling keys in the entries loop, matching the corrected form #7846 landed across six doubles in this package an hour earlier. Not the early-returning `if ($or) return …some(…)` shape those six carried before it: that discards sibling keys, so `{ id, $or: [...] }` would stop constraining `id` and could return some other commit whose org matched. This file was not among #7846's six because it had no operator handling to correct, so it is a new member of the #7620 lane rather than a regression of it. `undefined` normalises to `null` on comparison, same as the six, because a column a row never set reads as NULL out of a real driver. @objectstack/objectql: 185 files / 3274 tests passing (was 184/3272 with the two failures) — exactly the two cases restored, nothing else moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hxiiv8qFCUmDThHU1k7HV
58bef02 to
c21f09e
Compare
sys_metadata rows (#7819 tier 2)
#7871
Part of #7819
⛔
Part of, notFixes— deliberately. This is tier 1 only (revertCommit,rollbackToPackageCommit's target lookup). Tier 2 (duplicatePackage,reassignOrphanedMetadata) is a different table whose step one is the unanswered "are these states even reachable", and #7819 stays open to carry it.The defect
Both sites resolved their target commit with the strict equality the rest of this family carried:
organization_id = 'org'matches no row whose column is NULL, so an org-scoped caller gotCOMMIT_NOT_FOUND(404) for any commit recorded env-wide — a row that demonstrably exists and that the same caller'slistCommitshands back.Env-wide commit rows are not hypothetical:
recordPackageCommitstoresrequest.organizationId ?? null, and the publish door forwards an org only whenresolveActiveOrganizationIdyields one — a resolver that answersundefinedboth for a session with no active organization and for any throw on the auth seam. A publish made before an org was selected lands its commit env-wide, permanently, since the timeline is append-only.User-visible change: an org-scoped rollback past an env-wide publish now performs the rollback instead of refusing it. #7814 had already turned this from silent to loud (pre-#7814:
{success: true, revertedCommits: []}with the changes still live; after:success: falsenaming the commit), so this closes a blocked-but-attributable operation, not a silent data defect.The design decision — the
$orwas chosen, not copiedUnlike every earlier member of this family,
whereis keyed onid— a primary-key lookup — so the org predicate reads like an authorization filter on a unique key, and widening it would be widening an authorization boundary.Measured against the only door, it is not one:
POST /packages/:id/commits/:commitId/revertandPOST /packages/:id/rollbackisrequireManageMetadata, checked before the protocol call (packages/runtime/src/domains/packages.ts). The org never gates the call.organizationIdthat arrives is the session's active org selection fromresolveActiveOrganizationId(packages/runtime/src/http-dispatcher.ts), whose body is entirelycatch-wrapped.undefined, which omits the predicate — the widest reading, every organization's commits.A boundary that fails open is not a boundary. That rules out remedy 3 (keep the check, distinguish "not yours" from "no such commit"): there is no authorization here to make precise, and asserting one would be inventing a boundary rather than repairing one.
Remedy 2 (drop the predicate outright — defensible on an id lookup) was rejected because it would newly let an org caller revert another organization's commit by id, a widening this card never asked for. The
$oradmits the env-wide rows and refuses exactly that — pinned as its own case on both sites.The decisive in-code evidence: #7559 already made
revertCommitresolve each item's scope from the row rather than the request, with the rationale that "a batch legitimately mixes an env-wide artifact with an org overlay". Verified rather than taken on faith:resolveMetaItemOrgScopeanswersnull— env scope — for an item whose history is env-wide even when the request carries an org. The body already processed env-wide rows for an org caller while the lookup above refused to hand them over.rollbackToPackageCommitclosed the argument: since #7814 it plans fromlistCommits(org + env-wide) and fed each id straight back into a lookup that refused half of them — one function contradicting itself inside a single call.The no-org branch is deliberately not narrowed to
organization_id IS NULL, exactly as #7705 and #7779 left theirs: the direct-mount REST registrar passes noorganizationIdat all, and restricting that door to env-wide rows would make every org-scoped commit unrevertable — the same bug pointed the other way. Both no-org doors are pinned.Verification record
Premise re-verified at the branch point: exactly four occurrences remain, at
:11792,:11981,:12215,:12490. Positive control: 6 hits for theorganization_id: null($or) shape. After this PR only the two tier-2 sites (:11792,:11981) retain the strict equality.The pin —
packages/runtime/src/package-revert-commit-org-scope.integration.test.ts, a realObjectQLover a realSqlDriveron better-sqlite3, seeded through the real publish path. Real engine and real driver because the question is whetherorganization_id = 'org'matches a NULL column — a property of the driver's SQL, not of a stub'sfilter(). It lives inpackages/runtimebecausemetadata-protocolcannot importobjectql(dependency cycle). Eight cases:organization_idreally is NULL)revertCommitrevertCommitrevertCommitrollbackToPackageCommitrollbackToPackageCommitrollbackToPackageCommitrollbackToPackageCommitRefusals are asserted on
codeandstatus(COMMIT_NOT_FOUND/ 404) per ADR-0112, never on "it threw".Pre-fix measurement: the two positive cases failed with
COMMIT_NOT_FOUNDthrown atprotocol.ts:12218; all negative directions and both no-org doors already passed.The handoff assertion changed, as the card required.
package-list-commits-org-scope.integration.test.ts(#7814) pinnedrollback.success === false/failed == [c2]as a known-incomplete state; it now assertsfailed == [],success === true,revertedCommits == [c2]and survives as the family's end-to-end case.Reverse verification, direction predicted first: restoring the strict equality was predicted to turn exactly the two positive cases red plus the updated handoff assertion, leaving both negative directions and both no-org doors green (strict equality is narrower than the⚠️ For the next author: these suites resolve
$or). Measured: 3 failed | 11 passed — exactly those three.@objectstack/metadata-protocolthrough itsdist(stack traces are source-mapped back tosrc, which is misleading), so a source-only revert measures nothing — rebuild between measurements.Patch round — a blind test double, taught rather than accommodated
CI came back red on
packages/objectql/src/protocol-commit-history.test.ts: two org-scoped revert cases failed withCOMMIT_NOT_FOUND. Measured, not assumed — itsmatchesWherewas pure flat equality, so it comparedrow['$or']against the array and matched nothing.The double was the blind party, not the fix. Both failing rows carry the caller's own org (
organization_id: 'org_a', request org'org_a'), so they match the first$orbranch outright — the same row the strict equality already accepted. Nothing about their subject (#6602's registry org-asymmetry) involves the commit lookup; it is merely the door they enter through. The production fix was not weakened.It now understands
$or/$and, conjoined with the sibling keys in the entries loop — the corrected form #7846 landed across six doubles in this package (part of #7620) an hour before this round, deliberately matched rather than re-invented. Not the early-returningif ($or) return …some(…)shape those six carried before it: that discards sibling keys, so{ id, $or: [...] }would stop constrainingidand the lookup could return some other commit whose org matched. This file was not among #7846's six because it had no operator handling to correct, so it reads as a new member of the #7620 lane rather than a regression of it.Judgment asked for, answered plainly: the two assertions remain meaningful. Their subject is the registry org-asymmetry, not the predicate — the lookup is only how they reach it, and the rows they seed match the
$or's first branch, so nothing about the operator is doing the work. What would be a test of the double is any assertion whose subject is the org predicate; none exists in that file (which is exactly why it could never see this family), and a comment there now says so and asks that org-scoping cases not be added. The operator's real behaviour against a real driver stays pinned on the real engine inpackages/runtime. Flagging the residual honestly:matchesWhereis now a reimplementation of$orsemantics, and its fidelity to the real driver is itself unpinned.Gates (re-run after the rebase onto
55635fc)pnpm check:durability-log-levelpnpm check:changeset-gate-self-testspnpm check:nul-bytespnpm build(full)@objectstack/objectql@objectstack/metadata-protocol@objectstack/runtime@objectstack/client@objectstack/runtimetypecheckCoverage sweep after the miss: every suite in the repo that exercises
revertCommit/rollbackToPackageCommitwas enumerated and run —client,metadata-protocol,objectql,runtime.protocol-revert-org-scope.test.tsalready carries the corrected$orform from #7619; the remaining doubles are green unchanged.No new error code, so
check:error-code-casingdoes not apply.Scope discipline
packages/metadata-protocol/src/protocol.tsis serialized and was held for tier 1 alone: two hunks, at the two tier-1 sites, nothing else. Two adjacent observations were reported rather than acted on:revertCommitrecords its revert commit under the request's org even when the commit it reverted was env-wide. Pre-existing and unchanged here, but this fix makes that path newly reachable, so it is worth a look. It is at least self-consistent:listCommitswith the$orshows the resulting org-scoped revert commit to the same caller.Repricing input for tier 2: this change leaves
duplicatePackage/reassignOrphanedMetadatacompletely unaffected — different table (sys_metadata), no shared helper, no shared caller, no diff overlap. Tier 2's step one ("is the state reachable at all") is unchanged and its answer is not implied by anything measured here. One transferable asset: the boot/seed harness in the new suite is a working template for a real-enginesys_metadatapin, so tier 2's pinning cost is lower even though its measurement cost is not.