Skip to content

test(security): pin the four Layer 0 shapes as RECORDED VERDICTS at the middleware line (#15887) - #16275

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-15887-layer0-recorded-verdict-pins
Sep 6, 2026
Merged

test(security): pin the four Layer 0 shapes as RECORDED VERDICTS at the middleware line (#15887)#16275
os-zhuang merged 3 commits into
mainfrom
claude/issue-15887-layer0-recorded-verdict-pins

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #15887

Pins only. Nothing here claims anything is broken — all four shapes hold on main today, and the #15878 retarget of engine-data-events.test.ts that thinned them was honest: the five old positive pins drove setTenancyPostureProvider plus a stub driver with no SecurityPlugin composed 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 reads opCtx.tenantLayer0Verdict — the recorded object itself, never the downstream filter and never getReadFilter'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.ts file (+294 / -2 before the two gate fixes below).

Re-located first

The card cites security-plugin.ts:3088; the call is at :3154 on the tree this branch is cut from (:72 is the import). tenantAnchorIsPhantom has 3 sites in security-plugin.ts; intersectTenantLayer0Verdicts has 15 in tenant-layer.test.ts (the unit pins) and 1 in tenant-layer.ts. The recording site itself is security-plugin.ts:3171.

What the pins hold

shape recorded verdict fixture
systemFields.tenant: false beside an author-declared organization_id (P1) none shared_catalog — the readable column is asserted present, so the trap is explicit; a walled sibling on the same boot is the firing control
the #7835 phantom anchor none ext_customer, whose anchor is spread from TENANT_SCOPE_FIELD_DEF exactly as applySystemFields does
provenance control for the above organization ext_ledger — federated too, but the author declared a real remote column, so the exit is provenance and never external != null
a custom tenancy.tenantField beside the kernel column organization, and the predicate names organization_id workspace_doc
the same declaration with no kernel column none workspace_note — the custom column is never a substitute
the ADR-0090 D10 on-behalf-of intersection organizations ['org-2','org-3'] the middleware line, not the unit level

The on-behalf-of case deliberately asserts something neither wall names alone: under group the caller's memberships are ['org-1','org-2','org-3'] and the delegator's own (resolved from its sys_member rows, 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-running intersectTenantLayer0Verdicts' 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 verdict deny while the caller's half still names three organizations.

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 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 HEAD with git diff HEAD empty and git hash-object equal 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.

# mutation at the recording site predicted measured
A1 drop the systemFields.tenant === false clause from getObjectSecurityMeta's tenancyDisabled RED on the P1 pin GREEN — the prediction was wrong, and the on-disk proof says why: the clause has TWO occurrences, not one (see below)
A1b drop it from both producers RED on the P1 pin RED, none to organization org-1
A2 drop && !meta.tenantAnchorIsPhantom from objectHasOrgIdField RED on the phantom pin, provenance control green exactly that (1 failed / 21 passed)
A3 fold a declared tenancy.tenantField into tenancyDisabled — the R1 under-delivery RED on workspace_doc, workspace_note green exactly that
A4 let objectHasOrgIdField read the custom column as a stand-in RED on workspace_note, workspace_doc green exactly that
A5 delete the intersectTenantLayer0Verdicts call at the middleware line RED on both on-behalf-of pins, no-link control green exactly that (2 failed / 20 passed)

A1 is reported as measured rather than quietly re-run. computeLayeredRlsFilter reads this.tenancyDisabledCache.get(object) === true || meta.tenancyDisabled, and loadObjectFieldNames folds the same systemFields.tenant === false clause 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, including check:test-typecheck at 0 errors. Measured rather than assumed that the green covers the edited file: tsc -p tsconfig.test.json --listFiles puts it in the program (1 hit, 608 files, 100 of them .test.ts).
  • The gate family was derived mechanically on this head — 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 with EXIT=$? 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 a limit (200 on the sys_member legs) and the double ignored it. The bound is now applied after the filter and by presence.
  • check:where-matchermatches 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.

Changeset

skip-changeset, judged rather than defaulted. The entire diff is one .test.ts file. @objectstack/plugin-security publishes files: ["dist", "README.md", "CHANGELOG.md"] and builds from src/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), and gh is 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.ts plus three new pin files, none of them this one. explain-engine.ts was read here and not edited.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…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
…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
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6573af9a4508a6ace58de1922894237400b638c1packageMentionDocs.

@os-warren os-warren added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 6, 2026 — with Claude
@github-actions github-actions Bot added the tests label Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

PM verification — PASS

Measured at head c0ac779c, independently of the report.

Fence — held, and it is the whole Clause-② basis

Local merge-base diff: 1 file, tenant-layer0-verdict-on-operation.test.ts, +313/−2. git diff --name-only | grep -vc '\.test\.ts$'0. No source file moved, which is exactly what the Clause-②: no I filed at dispatch (5558035776) rested on. ⇒ It holds as declared, no correction needed.

⚠️ Note the collision fence also held against PR #16148, which is open on plugin-security/src/explain-engine.ts — disjoint, as measured before dispatch.

Re-location — the card's anchor was wrong, as warned

I checked this myself because it was the brief's first instruction:

origin/main:.../security-plugin.ts:72    intersectTenantLayer0Verdicts,
origin/main:.../security-plugin.ts:3154  tenantLayer0Verdict = intersectTenantLayer0Verdicts(...)

The card said :3088. It is :3154 — off by 66. ⭐ A seat that had trusted the offset would have pinned the wrong line or reported the symbol missing.

⭐ The ablation — and the prediction that was WRONG

This was a pins-only card, so the ablation is the deliverable, and I judged it on that. Five mutations at the recording site, direction predicted in the script before running, each mutation proven on disk by counting both an injected marker up and the removed anchor text down — never an editor's exit code — and each restore proven by git diff HEAD empty and a blob-hash match, under a trap with an absolute path.

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

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — no gate · PASS · landing by this seat — PR #16275 at head c0ac779c (Fixes #15887)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 10:13Z; taken here because the domain:services seat's tier fuse is blown.

Clause ② answer: no. The whole diff is one .test.ts under @objectstack/plugin-security, which publishes files: ["dist", …] from src/index.ts; no published byte, signature or payload moves and no behaviour changes. skip-changeset is the right disposition, applied by the seat.

Content read in full (371-line diff): the four shapes that were correct by construction are now pinned on the recording side — opCtx.tenantLayer0Verdict read off one middleware pass with the injected predicate beside it as the control — rather than one layer away on the projection's own pins. Fixtures are honest about their traps (the P1 opt-out asserts the readable organization_id is present; the #7835 phantom anchor is spread from the shipped TENANT_SCOPE_FIELD_DEF exactly as applySystemFields does, with the author-declared remote column as the provenance control; the custom tenancy.tenantField shape in both directions). The ADR-0090 D10 intersection case asserts a set neither wall names alone (['org-2','org-3'] from [org-1,2,3] ∩ [org-2,3,9]), with the no-link control and the empty-membership deny control through a read (the write twin is refused by ADR-0123 D2 before any wall composes — correctly noted). The fake engine's find applies the caller's limit by presence and matches refuses combinators it does not implement — both gate findings (check:objectql-double-limit, check:where-matcher) fixed in the double rather than worked around. Ablation table A1–A5 is in the predicted direction, with A1's surprise (the clause has two producers, so removing one leaves the verdict unmoved) reported as a redundancy rather than quietly re-run. Pre-existing cases byte-identical without a seed.

CI at c0ac779c: 30 success · 11 skipped · 0 failing. Governed-merge audit: 0/1.

Carriers: --pair 16275 exits 4 only because the label sits on the PR and not the card (C1 split); with the review done the PR label comes off now, both carriers are bare and the card's claim (5558035776) carries the Clause-②: line ⇒ re-run and land: ready-for-review + auto-merge (squash).


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 6, 2026 10:13
@os-zhuang
os-zhuang enabled auto-merge September 6, 2026 10:13
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit f0d5b64 Sep 6, 2026
46 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15887-layer0-recorded-verdict-pins branch September 6, 2026 10:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

3 participants