test(security): pin the four Layer 0 shapes as RECORDED VERDICTS at the middleware line (#15887) - #16275
Conversation
…he middleware line (#15887) Nothing here claims anything is broken — all four shapes hold on `main` today. What was missing is a reading on the RECORDING side. Each rested on the projection's own pins, i.e. on the identity "the recorded verdict IS the object the predicate was projected from" — the identity a future change to this seam would break first, and when it breaks the projection-side pins stay green. Adds middleware-level pins in `tenant-layer0-verdict-on-operation.test.ts` that read `opCtx.tenantLayer0Verdict` — the recorded object itself, not the downstream filter and not `getReadFilter`'s projection — for: * `systemFields.tenant: false` beside an author-declared `organization_id` (P1): `none`, with the readable column asserted as present so the trap is explicit, plus a walled sibling as the firing control; * the #7835 phantom anchor: a federated object carrying the anchor spread from `TENANT_SCOPE_FIELD_DEF` records `none`, while a federated object whose author declared a real remote `organization_id` records `organization` — the exit is provenance, never `external != null`; * a custom `tenancy.tenantField`, both directions: not an exit by itself (still `organization`, and the predicate names `organization_id`, never `workspace_id`), and never a substitute (no kernel column ⇒ `none`); * the ADR-0090 D10 on-behalf-of INTERSECTION at the middleware line, which had no pin at all. `intersectTenantLayer0Verdicts` is unit-pinned in `tenant-layer.test.ts`; what had no reading is the line that calls it. The case asserts an organization set NEITHER injected wall names alone, with the same fixture minus the delegation link as the control, plus the fail-closed `deny` leg on the read shape. `boot()` grows an optional delegator seed: without it the tables are empty, `findOne` answers `null` for every object as before, and no `find` is exposed — the existing cases are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…yer0-recorded-verdict-pins
…es combinators (#15887) Two findings from the derived gate family, both about the fake engine the on-behalf-of leg reads through — not about the pins: * `check:objectql-double-limit`: core's grants resolution hands every read a `limit` (200 on the `sys_member` legs), and the double ignored it. The bound is now applied AFTER the filter and BY PRESENCE, the shape the gate names. * `check:where-matcher`: `matches` compared a `$`-prefixed key as a FIELD NAME, which matches nothing and says nothing. The double now REFUSES the combinators and operators it does not implement — plain equality is all the delegator resolution ever asks it for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
PM verification — PASSMeasured at head Fence — held, and it is the whole
|
| leg | result |
|---|---|
A2 — drop && !meta.tenantAnchorIsPhantom |
RED, 1 failed — exactly the phantom-anchor pin; provenance control green |
A3 — fold tenancy.tenantField into tenancyDisabled (the historical R1 under-delivery) |
RED, 1 failed — exactly the workspace_doc pin |
A4 — let objectHasOrgIdField read the custom column as a stand-in |
RED, 1 failed — exactly the workspace_note pin |
A5 — delete the intersectTenantLayer0Verdicts call at the middleware line |
RED, 2 failed — both on-behalf-of pins; no-link control green |
A1 — drop the systemFields.tenant === false clause |
⭐ GREEN — prediction WRONG |
⭐ A1 is the most valuable result in this PR, and the seat reported it as measured rather than quietly re-running until it looked right. That is the behaviour I asked for and it paid: the on-disk proof showed the clause has two occurrences, and chasing that produced a structural reading I confirmed at the source:
security-plugin.ts:5606 const tenancyDisabled = this.tenancyDisabledCache.get(object) === true || meta.tenancyDisabled;
security-plugin.ts:7070 (obj as any)?.systemFields?.tenant === false || ← producer 1 → meta.tenancyDisabled
security-plugin.ts:7368 this.tenancyDisabledCache.set(objectName, !!tenancyDisabled); ← producer 2, same clause
tenancyDisabled has two independent producers OR-ed at the read site, so removing one still answers "opted out" and the pin cannot move. A1b removed both → RED, with the diff expected { kind: organization } to deeply equal { kind: none }.
⇒ All four shapes now have a pin that goes red under a mutation of the very thing it pins — which is the bar I set ("a pin that stays green under a mutation of the very thing it claims to pin is not a pin"). ⛔ And the A1/A1b pair is correctly recorded as a reading about a redundancy whose two halves agree, not a defect — nothing was touched there, which is right: the brief forbade fixing anything correct-by-construction.
⭐ Two gate reds — in the seat's own test double, not in the pins
check:objectql-double-limit and check:where-matcher both fired on the new fake engine the seat wrote: the double ignored the caller's limit, and its matches read a $-prefixed key as a field name. Both fixed in c0ac779c.
That is worth recording as a positive: these gates exist to stop a test double from being silently wrong in a way that makes the pins around it meaningless. A double that answers $or as a field name would have made some of these very assertions vacuous. ⛔ Not a defect in the subject, and the seat did not confuse the two.
What the pins actually assert
The recorded opCtx.tenantLayer0Verdict — ⛔ never the downstream filter, never getReadFilter's projection, which is the distinction the card is entirely about. Each shape carries its own discriminating control: the P1 case asserts the author-declared column present with a walled sibling as the firing control; the #7835 phantom anchor pairs with a federated-but-author-declared control so the exit is provenance rather than external != null; the custom tenancy.tenantField is pinned in both directions; and the ADR-0090 D10 intersection asserts a set that is neither injected wall's names alone (['org-1','org-2','org-3'] ∩ ['org-2','org-3','org-9'] → ['org-2','org-3']), with a no-delegation-link fixture and a no-membership deny leg as controls.
⭐ boot() grows an optional delegator seed, so without it the tables stay empty and findOne answers null as before — the 14 pre-existing cases are byte-identical. No existing pin was weakened or retargeted, which the brief made a ⛔.
Gates and the UNMEASURED discipline
48/48 exit 0, family derived mechanically and re-derived after a STALE TREE warning rather than trusted — identical list. Three first answered exit 3 = PREREQUISITE NOT MET, read as NOT MEASURED and converted into real verdicts by building the workspace closure; check:type-check-debt then needed one more build because the ablation had moved the file's mtime while the bytes stayed hash-identical. ⭐ That is a precise reading of why a gate was unhappy, not a retry until green.
typecheck covers the edited file — tsc --listFiles puts it in the program (grep count 1 of 608 files), closing the "typecheck excludes *.test.ts" false green rather than assuming it.
Verdict: PASS. needs:contract-review applied. skip-changeset judged rather than defaulted — the diff is one .test.ts, and plugin-security publishes only dist/README/CHANGELOG from src/index.ts, so this releases nothing.
Generated by Claude Code
Contract review (clause ②) — no gate · PASS · landing by this seat — PR #16275 at head
|
Fixes #15887
Pins only. Nothing here claims anything is broken — all four shapes hold on
maintoday, and the #15878 retarget ofengine-data-events.test.tsthat thinned them was honest: the five old positive pins drovesetTenancyPostureProviderplus a stub driver with noSecurityPlugincomposed at all, so they asserted the engine's own re-derivation against a wall that was never built. That is settled and is not re-litigated here.What was missing is a reading on the recording side. The seam's value is that the verdict the plugin records is the object the predicate is projected from — one computation, two readers. Four shapes rested on that identity holding, i.e. on the projection's own pins one layer away (
security-plugin.test.ts,federated-tenant-layer0.test.ts,tenant-layer.test.ts). The identity is exactly what a future change to this seam breaks first, and when it breaks, those pins stay green. Each case below readsopCtx.tenantLayer0Verdict— the recorded object itself, never the downstream filter and nevergetReadFilter's projection — off one middleware pass, with the injected predicate beside it as the control.No source file changed. The whole diff is one
.test.tsfile (+294 / -2 before the two gate fixes below).Re-located first
The card cites
security-plugin.ts:3088; the call is at:3154on the tree this branch is cut from (:72is the import).tenantAnchorIsPhantomhas 3 sites insecurity-plugin.ts;intersectTenantLayer0Verdictshas 15 intenant-layer.test.ts(the unit pins) and 1 intenant-layer.ts. The recording site itself issecurity-plugin.ts:3171.What the pins hold
systemFields.tenant: falsebeside an author-declaredorganization_id(P1)noneshared_catalog— the readable column is asserted present, so the trap is explicit; a walled sibling on the same boot is the firing controlnoneext_customer, whose anchor is spread fromTENANT_SCOPE_FIELD_DEFexactly asapplySystemFieldsdoesorganizationext_ledger— federated too, but the author declared a real remote column, so the exit is provenance and neverexternal != nulltenancy.tenantFieldbeside the kernel columnorganization, and the predicate namesorganization_idworkspace_docnoneworkspace_note— the custom column is never a substituteorganizations ['org-2','org-3']The on-behalf-of case deliberately asserts something neither wall names alone: under
groupthe caller's memberships are['org-1','org-2','org-3']and the delegator's own (resolved from itssys_memberrows, the one Layer 0 input a delegated context does not inherit) are['org-2','org-3','org-9']. Both walls are injected, in order; the recorded verdict is what a row must satisfy to clear both. Re-runningintersectTenantLayer0Verdicts' unit assertion in this file could not have done that. It ships with two controls: the identical fixture minus the delegation link records the caller's half, and a delegator with no membership at all makes the recorded verdictdenywhile the caller's half still names three organizations.boot()grows an optional delegator seed. Without it the tables are empty,findOneanswersnullfor every object as before, and nofindis exposed — the pre-existing cases are byte-identical.The ablation
Five mutations at the recording site, each predicted before running, each proven on disk (the injected marker counted up and the removed text counted down — an editor's exit code proves nothing), each restored from
HEADwithgit diff HEADempty andgit hash-objectequal to the HEAD blob. The subject is reached through a relative import (./security-plugin.js), so it resolves from source in this package and no rebuild leg applies; a red result is itself the proof the mutation was reached. Reproduced identically on the final head.systemFields.tenant === falseclause fromgetObjectSecurityMeta'stenancyDisablednonetoorganization org-1&& !meta.tenantAnchorIsPhantomfromobjectHasOrgIdFieldtenancy.tenantFieldintotenancyDisabled— the R1 under-deliveryworkspace_doc,workspace_notegreenobjectHasOrgIdFieldread the custom column as a stand-inworkspace_note,workspace_docgreenintersectTenantLayer0Verdictscall at the middleware lineA1 is reported as measured rather than quietly re-run.
computeLayeredRlsFilterreadsthis.tenancyDisabledCache.get(object) === true || meta.tenancyDisabled, andloadObjectFieldNamesfolds the samesystemFields.tenant === falseclause into that cache independently. Removing one producer leaves the other answering "opted out", so the verdict does not move — which is the correct behaviour for a pin on the verdict rather than on any one derivation. It is a redundancy whose two halves agree, not a defect, and it is not touched here.Verification
Final head
c0ac779c, and every number below was taken on it.pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/tenant-layer0-verdict-on-operation.test.ts— 22 passed (14 pre-existing + 8 new).pnpm --filter @objectstack/plugin-security test— 100 files, 1880 passed.pnpm --filter @objectstack/plugin-security typecheck— clean, includingcheck:test-typecheckat 0 errors. Measured rather than assumed that the green covers the edited file:tsc -p tsconfig.test.json --listFilesputs it in the program (1 hit, 608 files, 100 of them.test.ts).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 48 commands — and all 48 exit 0, each captured from a single redirected command withEXIT=$?on the next line, never through a pipe. Three of them (check:dual-build-cjs-loads,check:i18n,check:type-check-debt) first answered exit 3, PREREQUISITE NOT MET; that is not a pass, so the workspace build they name was run and all three were re-run to a real verdict.Two of those gates found real problems in this diff, both in the fake engine the on-behalf-of leg reads through rather than in the pins, and both are fixed here:
check:objectql-double-limit— core's grants resolution hands every one of those reads alimit(200 on thesys_memberlegs) and the double ignored it. The bound is now applied after the filter and by presence.check:where-matcher—matchescompared a$-prefixed key as a field name, which matches nothing and says nothing. The double now refuses the combinators and operators it does not implement; plain equality is all the delegator resolution ever asks it for.Changeset
skip-changeset, judged rather than defaulted. The entire diff is one.test.tsfile.@objectstack/plugin-securitypublishesfiles: ["dist", "README.md", "CHANGELOG.md"]and builds fromsrc/index.ts, so a test file is in neither the published set nor the entry's import graph. No published bytes move and no behaviour changes — the PR releases nothing.Scope
No out-of-scope finding was filed, because none was found: every one of the four shapes is correct by construction, exactly as the card says. The two gate findings above are about code added in this diff, so they are in scope and fixed rather than filed. The A1 redundancy is a note, not a defect.
The dedup channel is worth recording for the next seat: repo-scoped REST answers 403 in this container as well (
GitHub access is not enabled for this session), andghis absent — the same shape the card reported. Nothing needed filing, so no search was made on the strength of a refused channel.Stayed off #16148's paths: it touches
plugin-security/src/explain-engine.tsplus three new pin files, none of them this one.explain-engine.tswas read here and not edited.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code