Skip to content

Commit 58bef02

Browse files
committed
fix(metadata-protocol): let an org-scoped caller revert an env-wide commit (#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
1 parent 3bb9340 commit 58bef02

4 files changed

Lines changed: 534 additions & 16 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): let an org-scoped caller revert an env-wide commit (#7819, tier 1)
6+
7+
`revertCommit` and `rollbackToPackageCommit`'s target lookup each resolved
8+
their target commit with a strict `organization_id` equality:
9+
10+
```ts
11+
const where = { id: request.commitId };
12+
if (request.organizationId) where.organization_id = request.organizationId;
13+
```
14+
15+
`organization_id = 'org'` matches no row whose column is NULL, so an org-scoped
16+
caller got `COMMIT_NOT_FOUND` (404) for any commit recorded env-wide — a row
17+
that demonstrably exists and that the **same caller's** `listCommits` hands
18+
back. Both lookups now accept org-scoped **or** env-wide rows, the same `$or`
19+
`deletePackage` (#7705) and `listCommits` (#7779) already carry.
20+
21+
Env-wide commit rows are not hypothetical: `recordPackageCommit` stores
22+
`request.organizationId ?? null`, and the publish door forwards an org only when
23+
`resolveActiveOrganizationId` yields one — a resolver that answers `undefined`
24+
for a session with no active organization *and* for any throw on the auth seam.
25+
A publish made before an org was selected lands its commit env-wide,
26+
permanently, since the timeline is append-only.
27+
28+
**User-visible change.** An org-scoped rollback past an env-wide publish now
29+
performs the rollback instead of refusing it. #7814 had already converted this
30+
from silent to loud (pre-#7814: `{success: true, revertedCommits: []}` with the
31+
changes still live; after it: `success: false` naming the commit), so this
32+
closes a blocked-but-attributable operation rather than a silent data defect.
33+
34+
## Why the `$or` here, and not the other two remedies
35+
36+
Unlike the earlier members of this family, `where` is keyed on `id` — a
37+
primary-key lookup — so the org predicate reads like an **authorization filter
38+
on a unique key** rather than scan scoping, and widening it would be widening an
39+
authorization boundary. Measured against the only door, it is not one:
40+
41+
1. Authorization on `POST /packages/:id/commits/:commitId/revert` and
42+
`POST /packages/:id/rollback` is `requireManageMetadata`, checked **before**
43+
the protocol call. The org never gates the call.
44+
2. The `organizationId` that arrives is the session's *active org selection*
45+
from `resolveActiveOrganizationId`, whose body is entirely `catch`-wrapped.
46+
3. On any auth-seam throw it answers `undefined`, which **omits** the predicate
47+
— the widest reading, every organization's commits. A boundary that fails
48+
**open** is not a boundary.
49+
50+
That rules out remedy 3 (keep the check, distinguish "not yours" from "no such
51+
commit"): there is no authorization here to make precise, and asserting one
52+
would be inventing a boundary, not repairing one. Remedy 2 (drop the predicate
53+
outright, defensible on an id lookup) was rejected because it would newly let an
54+
org caller revert **another organization's** commit by id — a widening this card
55+
never asked for. The `$or` admits the env-wide rows and refuses that one.
56+
57+
The decisive in-code evidence is that the **body already accepted what the
58+
lookup refused**: #7559 made `revertCommit` resolve each item's scope from the
59+
row rather than the request, precisely because "a batch legitimately mixes an
60+
env-wide artifact with an org overlay". `rollbackToPackageCommit` made the
61+
contradiction self-evident — since #7814 it plans from `listCommits` (org +
62+
env-wide) and fed each id straight back into a lookup that refused half of them.
63+
64+
The **no-org branch is deliberately not narrowed** to `organization_id IS NULL`,
65+
exactly as #7705 and #7779 left theirs: the direct-mount REST registrar passes
66+
no `organizationId` at all, and restricting that door to env-wide rows would
67+
make every org-scoped commit unrevertable — the same bug pointed the other way.
68+
69+
## Pin
70+
71+
`packages/runtime/src/package-revert-commit-org-scope.integration.test.ts` — a
72+
real `ObjectQL` over a real `SqlDriver` on better-sqlite3, seeded through the
73+
real publish path, because the question is whether `organization_id = 'org'`
74+
matches a NULL column: a property of the driver's SQL, not of a stub's
75+
`filter()`. (It lives in `packages/runtime` because `metadata-protocol` cannot
76+
import `objectql` — dependency cycle.) Eight cases: the premise measured out of
77+
SQLite, the positive for each site, **both** negative directions (another
78+
organization's commit refused on each site; another package's commits not
79+
reached by the planner), and the no-org door on each site. Refusals are asserted
80+
on `code` **and** `status` per ADR-0112, never on "it threw".
81+
82+
`package-list-commits-org-scope.integration.test.ts` (#7814) carried the handoff
83+
assertion that pinned this defect as known-incomplete
84+
(`rollback.success === false`, `failed == [c2]`); it now asserts the rollback
85+
succeeds and reverts `c2`, and survives as the family's end-to-end case.
86+
87+
**Reverse verification**, direction predicted before running: restoring the
88+
strict equality turns exactly the two positive cases red plus the updated
89+
handoff assertion, and leaves both negative directions and both no-org doors
90+
green, since strict equality is *narrower* than the `$or`. Measured: 3 failed |
91+
11 passed, exactly those three.
92+
93+
## Scope
94+
95+
Tier 1 of #7819 only. The two remaining strict equalities in this file —
96+
`duplicatePackage` and `reassignOrphanedMetadata`, a different table
97+
(`sys_metadata`) whose step one is the unanswered "are these states even
98+
reachable" — are deliberately untouched, and #7819 stays open to carry them.

packages/metadata-protocol/src/protocol.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12212,7 +12212,52 @@ export class ObjectStackProtocolImplementation implements
1221212212
await this.ensureOverlayIndex();
1221312213
const orgId = request.organizationId ?? null;
1221412214
const where: Record<string, unknown> = { id: request.commitId };
12215-
if (request.organizationId) where.organization_id = request.organizationId;
12215+
// [#7819] Resolve BOTH org-scoped and env-wide (`organization_id IS
12216+
// NULL`) commit rows for an org-scoped caller — the same defect and
12217+
// remedy as the sibling {@link listCommits} (#7779) and {@link
12218+
// deletePackage} (#7705). `organization_id = <org>` matches no NULL
12219+
// column, so this answered `COMMIT_NOT_FOUND` (404) for a row that
12220+
// demonstrably exists and that the SAME caller's `listCommits`
12221+
// returns.
12222+
//
12223+
// ⚠️ This site is NOT the family's plain scan-scoping, and the `$or`
12224+
// was chosen over the two alternatives rather than copied. `where` is
12225+
// keyed on `id`, so the predicate reads like an AUTHORIZATION filter
12226+
// layered on a unique key. Measured against the only door, it is not
12227+
// one: authorization on `POST /packages/:id/commits/:commitId/revert`
12228+
// is `requireManageMetadata`, checked before this call; the
12229+
// `organizationId` that arrives is the session's *active org
12230+
// selection* from `resolveActiveOrganizationId`, whose body is
12231+
// entirely `catch`-wrapped and answers `undefined` on any auth-seam
12232+
// throw — and `undefined` omits this predicate, which is the WIDEST
12233+
// reading (every organization's commits). A boundary that fails OPEN
12234+
// is not a boundary, so there is no authz here to make precise; that
12235+
// rules out "keep it but distinguish 'not yours' from 'no such
12236+
// commit'". Dropping the predicate outright is defensible on an id
12237+
// lookup, but it would newly let an org caller revert ANOTHER
12238+
// organization's commit by id — a widening this card never asked for.
12239+
// The `$or` admits the env-wide rows and refuses that one.
12240+
//
12241+
// The body already agreed with this reading before the lookup did:
12242+
// #7559 made each item resolve its scope FROM THE ROW ({@link
12243+
// resolveMetaItemOrgScope}) precisely because "a batch legitimately
12244+
// mixes an env-wide artifact with an org overlay", so the loop below
12245+
// processes env-wide items for an org caller while the lookup above
12246+
// refused to hand them over. {@link rollbackToPackageCommit} made the
12247+
// contradiction self-evident: since #7814 it plans from `listCommits`
12248+
// (org + env-wide) and fed each id straight back into this lookup.
12249+
//
12250+
// The no-org branch is deliberately NOT narrowed to `organization_id
12251+
// IS NULL`, exactly as #7705 and #7779 left theirs: the direct-mount
12252+
// REST registrar passes no `organizationId` at all, and restricting
12253+
// that door to env-wide rows would make every org-scoped commit
12254+
// unrevertable — the same bug pointed the other way.
12255+
if (request.organizationId) {
12256+
where.$or = [
12257+
{ organization_id: request.organizationId },
12258+
{ organization_id: null },
12259+
];
12260+
}
1221612261
const row = (await this.engine.findOne('sys_metadata_commit', { where })) as any;
1221712262
if (!row) {
1221812263
const err: any = new Error(`[commit_not_found] No commit '${request.commitId}'.`);
@@ -12487,7 +12532,20 @@ export class ObjectStackProtocolImplementation implements
1248712532
failed: Array<{ commitId: string; error: string }>;
1248812533
}> {
1248912534
const where: Record<string, unknown> = { id: request.commitId };
12490-
if (request.organizationId) where.organization_id = request.organizationId;
12535+
// [#7819] Same widening as the {@link revertCommit} lookup above, and
12536+
// for the sharper reason: this function PLANS from {@link listCommits},
12537+
// which since #7814 returns org-scoped and env-wide rows alike to an
12538+
// org caller. With the strict equality here, an org-scoped rollback
12539+
// whose TARGET happened to be recorded env-wide answered 404 before it
12540+
// planned anything at all — for a commit the caller's own timeline had
12541+
// just listed. The rationale for the `$or` over the alternatives, and
12542+
// for leaving the no-org branch un-narrowed, is stated in full there.
12543+
if (request.organizationId) {
12544+
where.$or = [
12545+
{ organization_id: request.organizationId },
12546+
{ organization_id: null },
12547+
];
12548+
}
1249112549
const target = (await this.engine.findOne('sys_metadata_commit', { where })) as any;
1249212550
if (!target) {
1249312551
const err: any = new Error(`[commit_not_found] No commit '${request.commitId}'.`);

packages/runtime/src/package-list-commits-org-scope.integration.test.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -303,21 +303,30 @@ describe('#7779 — org-scoped listCommits must not hide env-wide commit rows',
303303
const commits = await p.listCommits({ packageId: PKG, organizationId: ACTIVE_ORG });
304304
expect(idsOf(commits)).toEqual([c1, c2].sort());
305305

306-
// ⚠️ KNOWN REMAINING GAP, measured and reported on #7779 rather than fixed
307-
// here — `packages/metadata-protocol/src/protocol.ts` is serialized and
308-
// this card holds it for `listCommits` alone.
306+
// [#7819 tier 1] ⭐ THE GAP THIS SUITE HANDED ON IS NOW CLOSED — these
307+
// lines are the handoff, and they changed exactly as it predicted.
309308
//
310-
// `revertCommit` (its own `findOne`) and `rollbackToPackageCommit` (its
311-
// target lookup) still carry the byte-identical strict equality. So the
312-
// planner now SEES C2 and asks `revertCommit` to undo it, and that lookup
313-
// still cannot find an env-wide row: the rollback reports
314-
// `success: false` naming C2, instead of the silent `success: true` it
315-
// reported before. That is strictly better — the failure is now loud,
316-
// attributable and non-destructive rather than invisible — but it is not
317-
// the whole repair, and this assertion is here so the remaining half
318-
// cannot drift unnoticed before its own card lands.
309+
// What they asserted until #7819: `revertCommit` (its own `findOne`) and
310+
// `rollbackToPackageCommit` (its target lookup) still carried the
311+
// byte-identical strict equality, because `protocol.ts` is serialized and
312+
// #7779 held it for `listCommits` alone. So the planner SAW C2 and asked
313+
// `revertCommit` to undo it, and that lookup could not resolve an env-wide
314+
// row — the rollback answered `success: false` naming C2. Already strictly
315+
// better than the silent `success: true` of before #7814 (loud,
316+
// attributable, non-destructive), but still a legitimate operation
317+
// blocked; the assertion existed so the remaining half could not drift
318+
// unnoticed before its own card landed.
319+
//
320+
// #7819 tier 1 widened both lookups to the same `$or` this suite pinned
321+
// for `listCommits`, so C2 now resolves and is actually undone. The case
322+
// survives as the family's END-TO-END pin: the planner sees the env-wide
323+
// commit (asserted above) AND can now act on it — the only combination
324+
// under which an org-scoped rollback past an env-wide publish does what it
325+
// reports. Its own negative directions live in the sibling
326+
// `package-revert-commit-org-scope.integration.test.ts`.
319327
const rollback = await p.rollbackToPackageCommit({ commitId: c1, organizationId: ACTIVE_ORG });
320-
expect(rollback.success).toBe(false);
321-
expect(rollback.failed.map((f: any) => f.commitId)).toEqual([c2]);
328+
expect(rollback.failed).toEqual([]);
329+
expect(rollback.success).toBe(true);
330+
expect(rollback.revertedCommits).toEqual([c2]);
322331
});
323332
});

0 commit comments

Comments
 (0)