Skip to content

Commit 4bc9821

Browse files
hotlongclaude
andauthored
fix(rest): the untyped /meta/diagnostics sweep forwards the caller's organization, so a governance summary stops undercounting its own drill-down (#15622) (#15726)
* fix(rest): the untyped /meta/diagnostics sweep forwards the caller's organization, so a governance summary stops undercounting its own drill-down (#15622) `GET /api/v1/meta/diagnostics` has two arms. The `?type=` arm has stated the organization since #13753; the untyped whole-registry sweep passed nothing, so the Studio governance summary reported clean tiles over a partition it never read while the per-type screen you reach by clicking into it could see the same items. A problem-reporting surface that structurally cannot see a class of problems issues a false all-clear. The obstacle recorded in the tree was that one `organizationId` could not express a per-type scope from this door without a fan-out per overridable type and a REST-side re-aggregation of `total`/`stats`/`scannedTypes`. #14683 dissolved it and #15034 recorded that: `getMetaDiagnostics` loops `for (const t of targetTypes)` calling `getMetaItems({ type: t, organizationId, ... })`, and the first thing `getMetaItems` does with that organization is `organizationIdForMetaRead(request.type, ...)` on its OWN folded type. One org id is therefore already narrowed per type by the callee. The door now resolves the memoised exec ctx for BOTH arms and passes `ctx?.tenantId` RAW on the untyped one. Deliberately NOT pre-folded with `organizationIdForMetaRead(...)` the way the `?type=` arm folds: there is no single type to fold on, and folding on any one of them would suppress the organization for every type at once. Identical in shape to the `/references` repair (e13ede8, #13753). The pin `an org-scoped item is absent from the whole-registry sweep` carried an explicit "if this reddens, read the card before making it green" note; #15622 is that card, so the assertion is REPLACED by its inverse rather than deleted, with the comment still pointing at the card. Beside it stands the narrowness control #15622 named as missing: in ONE request a planted pre-#6190 org-scoped row on a non-overridable type does NOT appear while an overridable type's org-authored row DOES — the half that tells a per-type gate from an unconditional tenant. Controls pin that org B is not served org A's items, that an organization-less caller reads exactly what it read before, that an env-wide item stays visible to an org caller, and that the wire shape is unchanged (same five response keys, same `stats` row keys, 200 either way). `resolveExecCtx` census numbers are unmoved: still 77 sites / 98 mentions, the hoisted resolution being the same single locally-caught continuation-line site. No new parameter, response field, status code or contract surface: ADR-0131 D6/D7 retires this partition in v18 (#15206, C5), so nothing is built on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(docs): re-anchor the system-context census lines the diagnostics comment shifted `check:check-system-context-census --fix` output: the five `rest-server.ts` elevation-read anchors on the system-context page moved by the same +32 lines the untyped arm's decision record added. Pure line rot, no row content changed. Part of #15622 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent eca5047 commit 4bc9821

4 files changed

Lines changed: 260 additions & 77 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
An organization-scoped caller's own items now appear in the untyped metadata diagnostics sweep.
6+
7+
`GET /api/v1/meta/diagnostics` has two arms. The `?type=` arm has stated the caller's organization since #13753; the untyped whole-registry sweep passed none, so the Studio governance summary reported clean tiles over a partition it never read — undercounting relative to the per-type drill-down screen you reach by clicking into it. A summary whose whole job is surfacing problems, and which structurally cannot see a class of them while its own drill-down can, issues a false all-clear. The untyped arm now forwards the caller's organization, so items that organization authored on the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`) are counted in `stats`, `total` and `scannedItems`.
8+
9+
The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field, status code or contract surface. There is no single type to fold on for a whole-registry sweep, and folding on any one of them would suppress the organization for every type at once; instead `getMetaDiagnostics` reads each swept type through `getMetaItems`, which applies the `allowOrgOverride` read gate to its own request type, so every type is scoped on its own registry flag. A non-overridable type (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into the report. An anonymous or organization-less caller reads exactly what it read before, and the `stats` / `total` / `scannedTypes` arithmetic is unchanged in shape.

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:5016`, `:6442`, `:6690`, `:7121`, `:7314` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5048`, `:6474`, `:6722`, `:7153`, `:7346` |
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:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `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/rest-server-meta-read-org-scope.test.ts

Lines changed: 171 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -568,40 +568,50 @@ describe('#13764 the history seams of this harness honour the org partition', ()
568568
// named no organization, so an org's own overlays were absent from it: clean
569569
// tiles rendered over a partition the sweep never read.
570570
//
571-
// ⭐ WHY ONLY THE `?type=` ARM IS REPAIRED, and why the untyped sweep is
572-
// PINNED AS-IS rather than left unmentioned. `getMetaDiagnostics` reads each
571+
// ⭐ BOTH ARMS STATE THE ORGANIZATION — and the split below is about WHERE the
572+
// fold happens, not about whether one happens. `getMetaDiagnostics` reads each
573573
// swept type through `getMetaItems({ type: t, organizationId })`.
574574
//
575575
// ⚠️ [#14683, recorded by #15034] `getMetaItems` NOW APPLIES THE REGISTRY GATE
576576
// ITSELF, after folding the request type. This header used to say it applied
577577
// none and that the scope was therefore the caller's to decide per type; that
578-
// sentence is FALSE on today's tree. What survives it is the arm split below,
579-
// which is about how many types ONE `organizationId` is asked to cover:
578+
// sentence is FALSE on today's tree. What that dissolved is the obstacle the
579+
// untyped arm was held shut on:
580580
//
581581
// • `?type=` ⇒ `targetTypes` is exactly that one type, so
582582
// `organizationIdForMetaRead` over it IS the request's whole scope. Correct
583-
// by construction, and repaired here.
583+
// by construction; repaired by #13753 and untouched since.
584584
// • no `?type=` ⇒ `targetTypes` is the whole registry, five
585-
// `allowOrgOverride: true` types beside every other declared type. The arm
586-
// names no organization at all, so nothing is folded and nothing is
587-
// unioned. ⚠️ The reason it stays that way is no longer "one org id cannot
588-
// say org-scoped for those five, env-wide for the rest" — since #14683 the
589-
// inner gate folds each `t` separately inside the sweep's own loop, so it
590-
// could. It stays because closing it MOVES BEHAVIOUR and is somebody's
591-
// decision on a card. The gap is pinned below so it cannot widen by
592-
// accident in either direction.
585+
// `allowOrgOverride: true` types beside every other declared type. ⭐
586+
// [#15622] This arm now forwards the caller's organization **RAW** and lets
587+
// the callee's per-`t` gate narrow it: the org for those five, `undefined`
588+
// for every other type, so no pre-#6190 phantom is unioned back in. ⛔ It
589+
// must NOT be pre-folded at the door — there is no single type to fold on,
590+
// and folding on any one of them would suppress the organization for every
591+
// type at once.
592+
//
593+
// ⚠️ THE GAP THIS SECTION USED TO PIN OPEN IS CLOSED, and the pin was REPLACED
594+
// rather than deleted. `an org-scoped item is absent from the whole-registry
595+
// sweep` carried "if this reddens, read the card before making it green";
596+
// #15622 is that card, and it ruled the arm forwards. Its inverse now stands in
597+
// the same place, beside the narrowness control #15622 named as missing — an
598+
// overridable type's org-authored row PRESENT and a planted phantom on a
599+
// non-overridable type ABSENT, on ONE request. Read #15622 before touching
600+
// either half: alone, neither can tell a per-type gate from an unconditional
601+
// tenant.
593602
//
594603
// ── ⛔ WHAT THIS FILE NO LONGER DISCRIMINATES (#15034, MEASURED) ───────────
595604
//
596605
// This header used to end: "Swap `organizationIdForMetaRead` for a raw
597606
// `ctx?.tenantId` at the call site and that assertion, and only it, turns red."
598-
// MEASURED on the merged tree, that ablation now leaves this file 30/30 GREEN
599-
// — `getMetaItems`' own gate re-folds the raw tenant id, phantom control
600-
// included. Same fate as #14677's ablation B, and for the same reason.
607+
// MEASURED on the merged tree, that ablation now leaves this file GREEN IN FULL
608+
// (30/30 at that revision; the file has grown since) — `getMetaItems`' own gate
609+
// re-folds the raw tenant id, phantom control included. Same fate as #14677's
610+
// ablation B, and for the same reason.
601611
//
602612
// ⇒ What this file DOES still discriminate is the organization being DROPPED:
603613
// remove the `organizationId` the `?type=` arm passes and the six repair cases
604-
// above turn red (measured: 6 failed / 24 passed). Read the two apart before
614+
// above turn red (measured at #15034: 6 failed / 24 passed). Read the two apart before
605615
// citing this file as a pin on the door-side predicate — it pins that the arm
606616
// still FOLDS, never that the fold happens at the door.
607617

@@ -757,35 +767,167 @@ describe('#13753 GET /meta/diagnostics states the org partition on the ?type= ar
757767
});
758768
});
759769

760-
describe('the RECORDED GAP — the untyped sweep is still env-wide', () => {
761-
it('an org-scoped item is absent from the whole-registry sweep', async () => {
762-
// ⚠️ This pins a KNOWN GAP, deliberately, so that closing it is a
763-
// decision somebody makes rather than a side effect: one
764-
// `organizationId` cannot express the per-type scope a
765-
// whole-registry sweep needs, and the shape is reported on the card
766-
// with a proposal. If this reddens, the untyped arm has started
767-
// naming an organization — read the card before making it green.
770+
describe('#15622 the whole-registry sweep states the org partition too', () => {
771+
it('⭐ THE CARD: an org-authored item on an overridable type IS counted', async () => {
772+
// ⚠️ THIS CASE REPLACES the pin `an org-scoped item is absent from
773+
// the whole-registry sweep`, which asserted the OPPOSITE and
774+
// carried "if this reddens, read the card before making it green".
775+
// #15622 IS that card. It ruled the untyped arm forwards the
776+
// caller's organization RAW, because since #14683 the callee folds
777+
// per swept type inside its own loop — so one org id now expresses
778+
// exactly the per-type scope the old pin said it could not. The
779+
// assertion is INVERTED rather than deleted so the next reader sees
780+
// the flip and its reason, and so the arm cannot drift back to
781+
// env-wide unnoticed.
782+
//
783+
// ⭐ Fixture proof first, for the same reason as the `?type=` cases
784+
// above: "the sweep is org-scoped" says nothing if the fixture never
785+
// created an org-scoped row.
768786
await b.put(CACHED_ARM, 'authored_at_runtime');
769-
expect(storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length).toBe(1);
787+
expect(
788+
storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length,
789+
'nothing landed in the org partition',
790+
).toBe(1);
791+
expect(
792+
storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', null).length,
793+
'the write also landed env-wide — the partition is not real',
794+
).toBe(0);
770795

771796
const swept = await b.diagnostics();
797+
expect(swept.thrown, `GET /diagnostics threw: ${swept.thrown?.message}`).toBeUndefined();
772798
expect(swept.status).toBe(200);
773799
expect(
774800
swept.body?.scannedTypes,
775801
'the untyped arm did not sweep the registry; the assertion below would be vacuous',
776802
).toBeGreaterThan(1);
777-
expect(swept.body?.stats?.[CACHED_ARM]?.count).toBe(0);
803+
expect(
804+
swept.body?.stats?.[CACHED_ARM]?.count,
805+
'the governance summary reported a clean tile over a partition it never read, '
806+
+ 'while its own ?type= drill-down could see the item — the card',
807+
).toBe(1);
778808
});
779809

780-
it('and still sees env-wide items — the zero above is scope, not a broken sweep', async () => {
810+
it('⛔ NARROWNESS CONTROL: a non-overridable type stays env-wide in the SAME sweep', async () => {
811+
// ⭐ THE HALF #15622 NAMED AS MISSING. Without it the change is
812+
// unmeasured: the case above passes just as well for a door that
813+
// hands the callee an UNCONDITIONAL tenant, and that door would
814+
// union a non-overridable type's org-scoped rows — the pre-#6190
815+
// phantoms `reportUnhydratableOrgScopedRows` warns about, which boot
816+
// hydration walks past — back INTO the governance report as `stats`
817+
// counts. A dashboard whose job is reporting what is wrong would
818+
// report rows that do not survive a restart. This control is what
819+
// proves the CALLEE'S PER-TYPE GATE is doing the work.
820+
//
821+
// `object` is `allowOrgOverride: false` + `allowRuntimeCreate: true`,
822+
// so its runtime writes land ENV-WIDE even under an active org
823+
// (`organizationIdForMetaWrite`, #6190) — which is why the phantom
824+
// has to be planted directly rather than written through the door.
825+
const written = await b.put(NON_OVERRIDABLE, 'accounts');
826+
expect(written.status, 'the control never wrote').toBe(200);
827+
expect(
828+
storedRowsFor(b.rows, NON_OVERRIDABLE, 'accounts', null).length,
829+
'a non-overridable write went org-scoped; the control no longer controls anything',
830+
).toBe(1);
831+
832+
b.rows.set(
833+
keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }),
834+
{
835+
id: 'phantom_sweep_1',
836+
type: NON_OVERRIDABLE,
837+
name: 'phantom_orders',
838+
organization_id: ORG_A,
839+
package_id: null,
840+
state: 'active',
841+
metadata: JSON.stringify(bodyFor(NON_OVERRIDABLE, 'phantom_orders')),
842+
},
843+
);
844+
expect(
845+
storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length,
846+
'the phantom was not planted; the control proves nothing',
847+
).toBe(1);
848+
849+
// ⭐ ONE REQUEST, BOTH TYPES — an org-authored `view` beside the two
850+
// `object` rows, so the two opposite scopes are read on ONE sweep.
851+
// That pairing is the fact neither assertion can state alone.
852+
await b.put(CACHED_ARM, 'authored_at_runtime');
853+
expect(storedRowsFor(b.rows, CACHED_ARM, 'authored_at_runtime', ORG_A).length).toBe(1);
854+
855+
const swept = await b.diagnostics();
856+
expect(swept.status).toBe(200);
857+
expect(
858+
swept.body?.stats?.[NON_OVERRIDABLE]?.count,
859+
'the untyped sweep read the org partition of a type with no per-org read channel — '
860+
+ 'the pre-#6190 phantoms, resurrected inside the governance report. The door passed '
861+
+ 'an unconditional tenant, or the callee stopped gating per type',
862+
).toBe(1);
863+
expect(
864+
swept.body?.stats?.[CACHED_ARM]?.count,
865+
'the overridable type lost its org scope on the same request — the gate is not per type',
866+
).toBe(1);
867+
});
868+
869+
it('does not sweep org A\'s items for org B on the same boot', async () => {
870+
await b.put(UNCACHED_ARM, 'tenant_bound');
871+
expect(storedRowsFor(b.rows, UNCACHED_ARM, 'tenant_bound', ORG_A).length).toBe(1);
872+
873+
b.as(ORG_B);
874+
const swept = await b.diagnostics();
875+
expect(swept.status).toBe(200);
876+
expect(
877+
swept.body?.stats?.[UNCACHED_ARM]?.count,
878+
'org B was swept over org A\'s items — forwarding became a cross-tenant read',
879+
).toBe(0);
880+
});
881+
882+
it('an org-LESS caller reads exactly what it read before', async () => {
883+
// ⛔ #15622 moves NO anonymous / organization-less read. This arm
884+
// resolves an exec ctx it did not resolve before, so the case that
885+
// names no org is the one that could regress silently.
886+
await b.put(CACHED_ARM, 'org_a_only');
887+
expect(storedRowsFor(b.rows, CACHED_ARM, 'org_a_only', ORG_A).length).toBe(1);
888+
889+
b.as(undefined);
890+
const swept = await b.diagnostics();
891+
expect(swept.status).toBe(200);
892+
expect(
893+
swept.body?.stats?.[CACHED_ARM]?.count,
894+
'an org-less caller was swept over an org-scoped item',
895+
).toBe(0);
896+
});
897+
898+
it('still sweeps env-wide items for an org-scoped caller', async () => {
899+
// The other direction: naming the org must not NARROW what an org
900+
// caller could already see.
781901
b.as(undefined);
782902
await b.put(CACHED_ARM, 'env_authored');
783903
expect(storedRowsFor(b.rows, CACHED_ARM, 'env_authored', null).length).toBe(1);
784904

785905
b.as(ORG_A);
786906
const swept = await b.diagnostics();
787907
expect(swept.status).toBe(200);
788-
expect(swept.body?.stats?.[CACHED_ARM]?.count).toBe(1);
908+
expect(
909+
swept.body?.stats?.[CACHED_ARM]?.count,
910+
'an org session lost sight of an env-wide item it could read before',
911+
).toBe(1);
912+
});
913+
914+
it('the response is the SAME wire shape — no new key, and 200 either way', async () => {
915+
// #15622 forwards an EXISTING value to an EXISTING parameter: no new
916+
// parameter, response field or status code. A repair that added a
917+
// scope discriminator to the envelope would satisfy every assertion
918+
// above and still be a contract change.
919+
await b.put(CACHED_ARM, 'authored_at_runtime');
920+
const swept = await b.diagnostics();
921+
expect(swept.status).toBe(200);
922+
expect(Object.keys(swept.body ?? {}).sort()).toEqual(
923+
['entries', 'scannedItems', 'scannedTypes', 'stats', 'total'],
924+
);
925+
// The `stats` ROW shape too — the arithmetic is unchanged in shape,
926+
// only in what the sweep can now see.
927+
expect(Object.keys(swept.body?.stats?.[CACHED_ARM] ?? {}).sort()).toEqual(
928+
['count', 'locked', 'packages'],
929+
);
930+
expect(typeof swept.body?.total).toBe('number');
789931
});
790932
});
791933
});

0 commit comments

Comments
 (0)