Skip to content

Commit a4e4d2d

Browse files
os-trumpclaude
andauthored
rest/meta: state the caller's organization on the /meta/diagnostics ?type= sweep (#14677)
* fix(rest): state the org partition on the /meta/diagnostics ?type= sweep `GET /api/v1/meta/diagnostics` never supplied an `organizationId`, so an organization's own overlays were absent from the Studio governance sweep — clean tiles over a partition it never read. `getMetaDiagnostics` already declares and reads the member; the gap was entirely at the REST call site. The `?type=` arm now resolves the memoised exec ctx and passes the registry-gated `organizationIdForMetaRead(canonicalMetaUrlType(type), …)`, matching the five sibling call sites in this file. The untyped whole-registry sweep is left env-wide on purpose and the reason is recorded at the call site: it spans types with different `allowOrgOverride` while the request carries one `organizationId`, and `getMetaItems` applies no registry gate of its own — so a tenant named there would union pre-#6190 phantom rows into a governance report. `GET /meta/:type/:name/references` is likewise left unchanged with its measurement recorded in place: `req.params.type` is the TARGET, while `findReferencesToMeta` spends the organization on the SOURCE types it sweeps, so no value this call site can pass expresses the correct scope. Pins extend the existing meta read org-scope harness, including the phantom-row control that discriminates the registry-gated predicate from a raw tenant id, and the recorded gap on the untyped arm. Part of #13753 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * chore(docs): re-anchor the system-context census after the rest-server edit `node scripts/check-system-context-census.mjs --fix` — five `ctx.isSystem` anchors on the permissions page moved because the diagnostics call site grew. Pure line rot; no elevation behaviour changed. The gate reads OK afterwards: 109 elevation read sites in 20 packages across 45 files, all anchored. Part of #13753 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * refactor(rest): resolve the diagnostics exec ctx in the house spelling, and count it Two coupled corrections found by running the package suite: 1. `execctx-consumer-census` counts every `this.resolveExecCtx(environmentId, req)` site in this file and pins the totals. The diagnostics door adds one, so 75 -> 76 sites, 95 -> 97 mentions (one call site plus one prose mention), 22 -> 23 locally caught. The bare/inline splits are unchanged. 2. The site is now resolved inside an `if (diagnosticsType)` block instead of a ternary. A ternary puts `.catch(rethrowAuthzStoreUnavailable)` on a continuation line with no trailing `;`, which is a THIRD layout the census's `catchArguments()` reader cannot see — it counted 23 caught sites and found 22 arguments, reddening the section 7 CONTROL that exists precisely so a reader which stops matching cannot look like a clean pass. Conforming to the spelling the sibling doors already use was preferred over teaching the reader a new layout. Part of #13753 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * chore(docs): re-anchor the system-context census after the call-site reshape `check-system-context-census.mjs --fix` again — the same five `ctx.isSystem` anchors moved by 3 lines when the diagnostics exec-ctx resolution became a statement. Pure line rot; the gate reads OK afterwards. Part of #13753 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * regen: re-anchor the system-context census on the merged tree pnpm gen:system-context-census, run after the merge commit. Row 50's five rest-server.ts citations move back to the branch's positions (the branch adds lines above them); rows 48/49 keep main's action-execution.ts anchors. Neither side's numbers were correct for the merged tree, which is why this is regenerated rather than hand-resolved. check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * chore(docs): re-anchor the system-context census from the merged tree `pnpm gen:system-context-census` re-derived row 50's five `rest-server.ts` citations after merging `origin/main`. Neither side's numbers were right for the merged tree, which is why this file is regenerated and never hand-resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba4587a commit a4e4d2d

5 files changed

Lines changed: 391 additions & 8 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
`GET /api/v1/meta/diagnostics?type=` now states the caller's organization
6+
7+
The cross-type spec-validation sweep behind the Studio governance directory named no
8+
organization, so an organization's own metadata overlays were absent from it — clean tiles
9+
rendered over a partition the sweep never read. The protocol implementation already
10+
declares and reads `organizationId`; only the REST call site never supplied one.
11+
12+
The `?type=` arm now resolves the request's memoised execution context and passes
13+
`organizationIdForMetaRead(canonicalMetaUrlType(type), ctx.tenantId)` — the same
14+
registry-gated predicate the list, single-item, `/layers`, `/history` and `/diff` doors
15+
already use, so read scope and write scope cannot drift: a type the registry declares
16+
`allowOrgOverride: false` keeps reading environment-wide, and an anonymous or
17+
organization-less caller reads exactly what it read before.
18+
19+
The untyped whole-registry sweep is deliberately unchanged and remains environment-wide:
20+
it spans types with different `allowOrgOverride` while the request carries a single
21+
`organizationId`, which cannot express a per-type scope. That gap is tracked on the card.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4715`, `:6078`, `:6326`, `:6757`, `:6950` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4789`, `:6203`, `:6451`, `:6882`, `:7075` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:326`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |

packages/rest/src/execctx-consumer-census.test.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,28 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
309309
// ---------------------------------------------------------------------------
310310

311311
describe('[#13160] §2 the consumer surface, counted from the tree', () => {
312-
it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => {
312+
it('76 invocation sites, 97 mentions — the thread\'s two control numbers hold', () => {
313+
// [#13753] 75 → 76 sites / 95 → 97 mentions. `GET /meta/diagnostics`
314+
// resolved NO identity, so the Studio governance sweep could not state
315+
// which organization's partition it was reading and reported clean
316+
// tiles over one it never read. It joins as a LOCALLY CAUGHT site (the
317+
// continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling),
318+
// like the `/history` and `/diff` doors before it: this door does not
319+
// sit behind the shared anonymous floor either.
320+
//
321+
// ⚠️ +1 and +2 again, for the same reason as below: one call site, and
322+
// one prose mention in the door's new comment recording that
323+
// `resolveExecCtx` is memoised per request so this is not a new
324+
// org-resolution seam.
325+
//
326+
// ⚠️ The site is resolved INSIDE an `if (diagnosticsType)` block rather
327+
// than in a ternary, and the shape is load-bearing for this census: a
328+
// ternary puts the `.catch(…)` on a continuation line with NO trailing
329+
// `;`, which is a THIRD layout {@link catchArguments} cannot read — it
330+
// would have counted 23 caught sites and found 22 arguments, i.e. the
331+
// §7 CONTROL failing rather than a silent hole. Conforming to the
332+
// house spelling was preferred over teaching the reader a layout.
333+
//
313334
// [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/
314335
// history` and `/diff` read doors resolved NO identity, so neither
315336
// could state which organization's `sys_metadata_history` partition it
@@ -340,11 +361,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
340361
// `enforceAuth` was measured NOT to be the repair). A mention count
341362
// that tracked the site count exactly would be measuring one thing
342363
// twice.
343-
expect(SITES.length).toBe(75);
344-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95);
364+
expect(SITES.length).toBe(76);
365+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(97);
345366
});
346367

347-
it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => {
368+
it('the split is 23 locally caught / 53 bare — NOT 16 / 53, which does not add to 76', () => {
348369
// 16 sites spell the catch on the invocation line; 4 more spell it on
349370
// the continuation line. A single-line grep sees 16 and the arithmetic
350371
// silently loses four sites.
@@ -354,12 +375,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
354375
// be the first of its kind and would break the structural claim below.
355376
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
356377
expect(sameLine.length).toBe(16);
357-
expect(CAUGHT.length).toBe(22);
378+
expect(CAUGHT.length).toBe(23);
358379
expect(BARE.length).toBe(53);
359380
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
360381
});
361382

362-
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => {
383+
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 23 caught ones is', () => {
363384
// This inverts the reason the thread gave for doing the bare sites
364385
// first ("no local signal that a fault becomes an anonymous subject").
365386
// The bare sites are bare BECAUSE the shared anonymous floor is the

packages/rest/src/rest-server-meta-read-org-scope.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,9 @@ function boot() {
338338
drive('GET', `${META}/:type/:name`, { params: { type, name } }),
339339
list: (type: string) =>
340340
drive('GET', `${META}/:type`, { params: { type } }),
341+
/** [#13753] The cross-type spec-validation sweep. */
342+
diagnostics: (query: Record<string, unknown> = {}) =>
343+
drive('GET', `${META}/diagnostics`, { query }),
341344
history: (type: string, name: string) =>
342345
drive('GET', `${META}/:type/:name/history`, { params: { type, name }, query: {} }),
343346
/** The fixture proof every history assertion below is gated on. */
@@ -555,3 +558,216 @@ describe('#13764 the history seams of this harness honour the org partition', ()
555558
).toEqual([]);
556559
});
557560
});
561+
562+
// ── [#13753] `GET /meta/diagnostics` ──────────────────────────────────────
563+
//
564+
// The cross-type spec-validation sweep behind the Studio governance directory
565+
// named no organization, so an org's own overlays were absent from it: clean
566+
// tiles rendered over a partition the sweep never read.
567+
//
568+
// ⭐ WHY ONLY THE `?type=` ARM IS REPAIRED, and why the untyped sweep is
569+
// PINNED AS-IS rather than left unmentioned. `getMetaDiagnostics` reads each
570+
// swept type through `getMetaItems({ type: t, organizationId })`, and
571+
// `getMetaItems` applies NO registry gate of its own — the organization it is
572+
// handed is used for whatever type it is handed. So the scope is per TYPE
573+
// while the request carries ONE `organizationId`:
574+
//
575+
// • `?type=` ⇒ `targetTypes` is exactly that one type, so
576+
// `organizationIdForMetaRead` over it IS the request's whole scope. Correct
577+
// by construction, and repaired here.
578+
// • no `?type=` ⇒ `targetTypes` is the whole registry, five
579+
// `allowOrgOverride: true` types beside every other declared type. One org
580+
// id cannot say "org-scoped for those five, env-wide for the rest", and
581+
// `getMetaItems` UNIONS the named org's rows onto the env-wide ones — so a
582+
// tenant named there would union pre-#6190 phantom rows (org-scoped rows on
583+
// types with no per-org read channel, which boot hydration walks past) back
584+
// into a governance report. The gap is reported on the card and pinned
585+
// below so it cannot widen by accident in either direction.
586+
//
587+
// The controls are the load-bearing half. `?type=object` proves the predicate
588+
// is the REGISTRY-GATED one: a phantom org-scoped `object` row is planted
589+
// directly in the store — the write door cannot produce one, by #6190 — and
590+
// the sweep must not see it. Swap `organizationIdForMetaRead` for a raw
591+
// `ctx?.tenantId` at the call site and that assertion, and only it, turns red.
592+
593+
/** Rows in the backing store for one `(type, name, org)` slot. */
594+
function storedRowsFor<T extends { type: string; name: string; organization_id: string | null }>(
595+
rows: Map<string, T>,
596+
type: string,
597+
name: string,
598+
org: string | null,
599+
): T[] {
600+
return Array.from(rows.values()).filter(
601+
(r) => r.type === type && r.name === name && (r.organization_id ?? null) === org,
602+
);
603+
}
604+
605+
describe('#13753 GET /meta/diagnostics states the org partition on the ?type= arm', () => {
606+
let b: ReturnType<typeof boot>;
607+
beforeEach(() => { b = boot(); });
608+
609+
describe('the repair — a ?type= sweep sees what this organization authored', () => {
610+
it.each(ORG_OVERRIDABLE)('%s: the org-scoped item is counted', async (type) => {
611+
const written = await b.put(type, 'authored_at_runtime');
612+
expect(written.status, `PUT /${type} was not accepted`).toBe(200);
613+
614+
// ⭐ Fixture proof first. "The sweep is org-scoped" is worthless if
615+
// the fixture never created an org-scoped row — the assertion below
616+
// would then pass or fail for a reason unrelated to org scoping.
617+
expect(
618+
storedRowsFor(b.rows, type, 'authored_at_runtime', ORG_A).length,
619+
'nothing landed in the org partition',
620+
).toBe(1);
621+
expect(
622+
storedRowsFor(b.rows, type, 'authored_at_runtime', null).length,
623+
'the write also landed env-wide — the partition is not real',
624+
).toBe(0);
625+
626+
const swept = await b.diagnostics({ type });
627+
expect(swept.thrown, `GET /diagnostics threw: ${swept.thrown?.message}`).toBeUndefined();
628+
expect(swept.status).toBe(200);
629+
expect(swept.body?.scannedTypes, 'the ?type= arm swept more than the named type').toBe(1);
630+
expect(
631+
swept.body?.stats?.[type]?.count,
632+
'the sweep reported a clean tile over a partition it never read — the card',
633+
).toBe(1);
634+
expect(swept.body?.scannedItems).toBe(1);
635+
});
636+
637+
it('a plural URL spelling is folded before the scope decision, not after', async () => {
638+
// [#10340] The predicate is asked with `canonicalMetaUrlType(...)`,
639+
// never the raw segment: `declaresOrgOverride` answers `false` for
640+
// URL-only spellings, so an unfolded `views` would silently drop
641+
// back to env-wide and this case would report a clean tile again.
642+
await b.put(CACHED_ARM, 'authored_at_runtime');
643+
expect(storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length).toBe(1);
644+
645+
const swept = await b.diagnostics({ type: 'views' });
646+
expect(swept.status).toBe(200);
647+
expect(
648+
swept.body?.stats?.views?.count,
649+
'the plural spelling was scoped env-wide — the fold happened after the decision',
650+
).toBe(1);
651+
});
652+
});
653+
654+
describe('⛔ controls — the scope is STATED, never widened', () => {
655+
it('?type=object stays env-wide and does NOT resurrect a phantom org row', async () => {
656+
// ⭐ THE ABLATION TARGET. `object` is `allowOrgOverride: false` +
657+
// `allowRuntimeCreate: true`, so its runtime writes land ENV-WIDE
658+
// even under an active org (`organizationIdForMetaWrite`, #6190) —
659+
// which is why the phantom below has to be planted directly rather
660+
// than written through the door. Rows like it exist in deployments
661+
// that ran before that ruling; boot hydration walks past them, so
662+
// they are dead, and a read door that named the org for every type
663+
// would serve them again. PREDICTED DIRECTION: replace the
664+
// predicate with `ctx?.tenantId` at the call site and the count
665+
// below becomes 2.
666+
const written = await b.put(NON_OVERRIDABLE, 'accounts');
667+
expect(written.status, 'the control never wrote').toBe(200);
668+
expect(
669+
storedRowsFor(b.rows, NON_OVERRIDABLE, 'accounts', null).length,
670+
'a non-overridable write went org-scoped; the control no longer controls anything',
671+
).toBe(1);
672+
673+
b.rows.set(
674+
keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }),
675+
{
676+
id: 'phantom_1',
677+
type: NON_OVERRIDABLE,
678+
name: 'phantom_orders',
679+
organization_id: ORG_A,
680+
package_id: null,
681+
state: 'active',
682+
metadata: JSON.stringify(bodyFor(NON_OVERRIDABLE, 'phantom_orders')),
683+
},
684+
);
685+
expect(
686+
storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length,
687+
'the phantom was not planted; the control proves nothing',
688+
).toBe(1);
689+
690+
const swept = await b.diagnostics({ type: NON_OVERRIDABLE });
691+
expect(swept.status).toBe(200);
692+
expect(
693+
swept.body?.stats?.[NON_OVERRIDABLE]?.count,
694+
'the sweep read the org partition of a type with no per-org read channel — '
695+
+ 'the phantom rows #6190 stopped minting, resurrected on the read side',
696+
).toBe(1);
697+
});
698+
699+
it('does not sweep org A\'s items for org B on the same boot', async () => {
700+
await b.put(UNCACHED_ARM, 'tenant_bound');
701+
expect(storedRowsFor(b.rows, UNCACHED_ARM, 'tenant_bound', ORG_A).length).toBe(1);
702+
703+
b.as(ORG_B);
704+
const swept = await b.diagnostics({ type: UNCACHED_ARM });
705+
expect(swept.status).toBe(200);
706+
expect(
707+
swept.body?.stats?.[UNCACHED_ARM]?.count,
708+
'org B was swept over org A\'s items',
709+
).toBe(0);
710+
});
711+
712+
it('does not serve an org-scoped item to a caller that named no org', async () => {
713+
await b.put(CACHED_ARM, 'org_a_only');
714+
expect(storedRowsFor(b.rows, CACHED_ARM, 'org_a_only', ORG_A).length).toBe(1);
715+
716+
b.as(undefined);
717+
const swept = await b.diagnostics({ type: CACHED_ARM });
718+
expect(swept.status).toBe(200);
719+
expect(
720+
swept.body?.stats?.[CACHED_ARM]?.count,
721+
'an org-less caller was swept over an org-scoped item',
722+
).toBe(0);
723+
});
724+
725+
it('still sweeps env-wide items for an org-scoped caller', async () => {
726+
// The other direction of the same harness: naming the org for org
727+
// callers must not disturb the env-wide read that worked all along.
728+
b.as(undefined);
729+
await b.put(CACHED_ARM, 'env_authored');
730+
expect(storedRowsFor(b.rows, CACHED_ARM, 'env_authored', null).length).toBe(1);
731+
732+
b.as(ORG_A);
733+
const swept = await b.diagnostics({ type: CACHED_ARM });
734+
expect(swept.status).toBe(200);
735+
expect(
736+
swept.body?.stats?.[CACHED_ARM]?.count,
737+
'an org session lost sight of an env-wide item it could read before',
738+
).toBe(1);
739+
});
740+
});
741+
742+
describe('the RECORDED GAP — the untyped sweep is still env-wide', () => {
743+
it('an org-scoped item is absent from the whole-registry sweep', async () => {
744+
// ⚠️ This pins a KNOWN GAP, deliberately, so that closing it is a
745+
// decision somebody makes rather than a side effect: one
746+
// `organizationId` cannot express the per-type scope a
747+
// whole-registry sweep needs, and the shape is reported on the card
748+
// with a proposal. If this reddens, the untyped arm has started
749+
// naming an organization — read the card before making it green.
750+
await b.put(CACHED_ARM, 'authored_at_runtime');
751+
expect(storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length).toBe(1);
752+
753+
const swept = await b.diagnostics();
754+
expect(swept.status).toBe(200);
755+
expect(
756+
swept.body?.scannedTypes,
757+
'the untyped arm did not sweep the registry; the assertion below would be vacuous',
758+
).toBeGreaterThan(1);
759+
expect(swept.body?.stats?.[CACHED_ARM]?.count).toBe(0);
760+
});
761+
762+
it('and still sees env-wide items — the zero above is scope, not a broken sweep', async () => {
763+
b.as(undefined);
764+
await b.put(CACHED_ARM, 'env_authored');
765+
expect(storedRowsFor(b.rows, CACHED_ARM, 'env_authored', null).length).toBe(1);
766+
767+
b.as(ORG_A);
768+
const swept = await b.diagnostics();
769+
expect(swept.status).toBe(200);
770+
expect(swept.body?.stats?.[CACHED_ARM]?.count).toBe(1);
771+
});
772+
});
773+
});

0 commit comments

Comments
 (0)