feat(security,objectql): the Layer 0 wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall (#15813) - #15878
Conversation
…event producer reads it (#15813) Work in progress — spec verdict schema, plugin-security recording, engine reader, retargeted pins, regenerated spec artifacts. Not yet verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…nerated spec artifacts (#15813) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…yer0-verdict-on-operation
…yer0-verdict-on-operation
…yer0-verdict-on-operation # Conflicts: # content/docs/permissions/system-context.mdx
…ed tree Discharges the os-regen deferral recorded by the merge commit: the driver kept one side of the generated index, and gen:schema + gen:docs on the merged tree re-derives it with both sides' entries (EvaluatedExpression from main, TenantLayer0Verdict from this branch). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 3 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 133 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e823af146e67ecf69a2cb042249fc7fde88e4e0c && git checkout e823af146e67ecf69a2cb042249fc7fde88e4e0c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 99a5bc674bcc3b5846303f5d7abcc5331574736c 2165bc8b7bdd922ed362c79fc41729e656afc9e6 && git checkout -B drift-repro 99a5bc674bcc3b5846303f5d7abcc5331574736c && git merge --no-ff 2165bc8b7bdd922ed362c79fc41729e656afc9e6
node scripts/docs-audit/affected-docs.mjs --json 99a5bc674bcc3b5846303f5d7abcc5331574736c
|
Closes #15813
Implements the seam ruled on #15706 (
5549576962, option (i)):plugin-securityrecords its Layer 0 verdict on the operation context at the moment it composes the wall, and the bulk data-event producer in the engine reads that recorded verdict and composes nothing.The acceptance criterion, answered with the code
bulkEventOrganizationId(recordedVerdict: unknown)(packages/objectql/src/engine.ts) has ONE input: the value the plugin recorded. Its body is aTenantLayer0VerdictSchema.safeParseand twokindreads; it names no posture, notenantId, noaccessible_org_ids, no rung, no schema. The recorded value iscomputeTenantLayer0Verdict(...)'s result — the same call that produces the injected predicate (computeTenantLayer0Filteris nowtenantLayer0FilterOf(computeTenantLayer0Verdict(input))), so it cannot be re-derived from anything else on the context: its deciding inputs include the deployment'splatformGlobalObjectscarve-out (#12699) and the capability probe, neither of which the context carries. The engine'senforcedTenancyPosture()accessor, its imports ofcarriesTenantScopeColumn/AuthzPostureSchema/postureEnforcesWall/postureUsesUnionScope, and the module-level export ofcarriesTenantScopeColumn(registry.ts) are all deleted — code-only counts inengine.ts: 0 / 0 / 0 / 0 / 0. The mirror is deleted, not moved.What changed
@objectstack/specTenantLayer0VerdictSchema/TenantLayer0Verdict(security/tenant-layer0-verdict.ts):none·organization·organizations(a deduplicated, non-empty set) ·deny;.strict(), junk fails to parseminor@objectstack/objectqlOperationContext.tenantLayer0Verdict?: TenantLayer0Verdict(additive member on a published surface);publishBulkDataEventreads it viabulkEventOrganizationId; both call sites passopCtx.tenantLayer0Verdict; the #15687 mirror (enforcedTenancyPosture, the posture/context/schema derivation) removedminor@objectstack/plugin-securitytenant-layer.ts:computeTenantLayer0Verdict,tenantLayer0FilterOf,intersectTenantLayer0Verdicts;computeTenantLayer0Filterbecomes the projection.security-plugin.ts:computeLayeredRlsFilterreturns the verdict beside the layers (the #12974 owner read-bypass returnsnone); step 3 (the AST injection, reads and predicate writes) composes from the split with the sameandComposeLayers— the injected predicate is byte-identical — and recordsopCtx.tenantLayer0Verdict; an on-behalf-of operation records the intersection of the caller's and the delegator's wallsminorRecorded for every operation whose predicate the wall is composed onto (reads and predicate writes). A system context takes the middleware's first exit and a by-id write carries no
ast, so neither records anything — absence is a distinct, honest state fromnone, and both read as "not asserted".Populations that move, and in which direction
platformGlobalObjectsexemption is invisible to the engine, so the p0 fix can MISLABEL a cross-org batch #15706 population): key was WRONG (the caller's organization over a batch Layer 0 never constrained) → ABSENT;PLATFORM_ADMINrung on a PUBLIC tenant object: was conservatively absent → PRESENT (the wall stands there; the plugin knows);Every population the former producer answered correctly is unchanged — and every one of those answers now comes from the plugin, not from a re-derivation.
Pins (each names the line that goes red if the seam is lost)
packages/spec/src/security/tenant-layer0-verdict.test.ts— 5 accepted shapes, 13 refusals ('', empty set, duplicate, extra key, filter shape, bare string, …).packages/plugins/plugin-security/src/tenant-layer.test.ts(+16) — the verdict per exit; the filter IS the projection on every exit;denyspreads the sentinel; the intersection table.packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts(new, 14) — one middleware pass, verdict AND injected predicate read together; the deployment carve-out recordsnonewith a firing control on the sibling object;PLATFORM_ADMINon public →organization, on private →none; rungless probe;denyon an org-less delete; nothing recorded for a system context or a by-id write;getReadFilterbyte-identical.packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts(new, 3) — realObjectQL+SqlDriver+ realSecurityPlugin+ captured realtime: the walled object publishesorg_acme; the exempted object publishes the key ABSENT (ground truth via raw knex: rows in two organizations); no declaration ⇒ walled again. This is the only pin that catches a member-name drift between the plugin's write and the engine's read.packages/objectql/src/engine-data-events.test.ts— the objectql:publishBulkDataEventdoes not stamp the batchorganizationIdthe spec now declares (PR #15218) — the bulk producer half of the #13566 p0 cross-tenant webhook leak #15225 block RETARGETED (see below): a recordedorganizationstamps the verdict's organization whiletenantIdsays another; one-memberorganizationspresent, two absent;none/denyabsent; no recorded verdict ⇒ absent underisolated+ a member with an active organization (the mirror-deleted pin); six junk shapes absent-and-still-published; the source pin now also assertsbulkEventOrganizationId's body namesTenantLayer0VerdictSchema.safeParse(and none oftenantId/accessible_org_ids/posture/isSystem/carriesTenantScopeColumn/getObject(/enforcedTenancyPosture/resolveEnginePosture.Zone 2 assumptions — one falsified, stated plainly
engine-data-events.test.tsdid NOT all stay green: the objectql:publishBulkDataEventdoes not stamp the batchorganizationIdthe spec now declares (PR #15218) — the bulk producer half of the #13566 p0 cross-tenant webhook leak #15225 block's five positive pins (isolated update/delete, group singleton, group repeated, customtenantField) drove no SecurityPlugin at all — onlysetTenancyPostureProviderplus a stub driver — so they were asserting what the engine's mirror re-derived from posture + context with no wall ever composed. Under (i) an engine that records no verdict answers absent, and those pins flip. They were retargeted deliberately and visibly (15 → 10itentries in that block, 42 → 43 tests in the file withit.eachexpansion); the object-shape and rung-shape populations now live where the wall is computed, and the end-to-end weld covers the composition.opCtxis one object throughexecuteWithMiddleware, and both call sites hold it — no new parameter beyondpublishBulkDataEvent's private input bag.computeTenantLayer0Filter's return (a filter) carries the answer, but a filter shape is not a contract — so what is recorded is a typed verdict (TenantLayer0VerdictSchema), and the filter is its projection. Thegroupset is deduplicated at the source so a reader may testlength === 1.Out of scope, filed
DriverOptions.tenantId→applyTenantScope) does not honour the deployment'splatformGlobalObjectscarve-out (#12699): an exempted object stays walled at the driver while Layer 0 composes nothing #15831 (sub-issue of feat(spec,drivers,objectql,plugin-security):organization_idNOT NULL per cleared table; one predicate for Layer 0 and every driver; bothorWhereNullarms, the__global__sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212, ADR-0131 C8): the driver-level tenant scope (DriverOptions.tenantId→applyTenantScope) does not honour the feat(spec,security): OrgScopingEntitlement grows platform-global exemption + unbounded-admin suppression, consumed by Layer 0 arming #12699 carve-out — measured in the end-to-end pin: the exempted object's sweep matched 1 row, not 2, while Layer 0 recordednone. Safe direction (over-walling); not this seam's to change, and the pin records the measurement rather than asserting a driver behaviour this card does not own.Docs
content/docs/permissions/system-context.mdxrow 30 re-anchored from the deleted engineisSystemread to the middleware's short-circuit (security-plugin.ts:1620), and the six census-derived counts decremented (107 → 106, 113 → 112, 103 → 102);check-system-context-census: OK — 106 sites, 141 anchors resolve.Verification at the final head
cad1395faAll figures below are from runs at
cad1395fa(the merge oforigin/mainat59953d5a3), exit codes captured before any pipe, verdict lines quoted from each gate's own output.pnpm --filter @objectstack/spec build→VERDICT command-exit 0;pnpm --filter @objectstack/spec check:generated→✓ All 15 generated artifacts are up to date.;pnpm --filter @objectstack/objectql build→VERDICT command-exit 0(dist reader present:TenantLayer0VerdictSchema.safeParse×1;carriesTenantScopeColumn(code sites in dist: 2, both registry-internal).@objectstack/spec,@objectstack/objectql,@objectstack/plugin-security→ eachVERDICT command-exit 0.scripts/pm/os-verify-lock.sh,--maxWorkers=2): objectqlengine-data-events.test.ts41/41 (16 in the retargeted block) +engine-middleware-operation-vocabulary.test.ts5/5 →Tests 46 passed (46); plugin-security FULL package →Test Files 97 passed · Tests 1852 passed (1852)(includes the 14 recording pins, the 3 end-to-end welds and the +16 tenant-layer pins); spectenant-layer0-verdict.test.ts+tenancy-posture.test.ts→Tests 37 passed (37).node scripts/pm/dispatch-gates.mjs --commands, no paths, derived atcad1395fa: 105 commands): 102 green; 3 NOT MEASURED, each by its own text —check:dual-build-cjs-loads(⛔ This is NOT a pass: nothing was measured— needspnpm buildof the whole repo),check:i18n(PREREQUISITE NOT MET — the workspace CLI is not built),check:type-check-debt(PREREQUISITE NOT MET). Whole-repo builds are CI's run; declared here, not skipped silently.check:skill-examplesneeded@objectstack/client-reactbuilt first and then answered✅ 257 prose examples type-check.check:system-context-census→OK — 106 elevation read sites in 20 packages across 45 files, all anchored.eslint.config.mjsitself (8 config entries, 5 global-ignore entries, noprojectService/parserOptions.project— type-aware linting is off, so this diff cannot move any untouched file's verdict); ②eslint --no-inline-config --format jsonover the 11 touched lintable files → 11 files linted, 0 errors, 0 warnings, exit 0; ③ config unmoved in the incoming merge range.Execution terms from the ruling
plugin-sharingmatchingERR_SYSTEM_WRITE_ORGANIZATION_REQUIREDfits neither existing remedy cleanly #14937 precedent). 🔔 Thedomain:servicesseat adopts on the card (Implement the ruled seam (i):plugin-securityrecords its Layer 0 verdict on the operation, and the bulk-event publish site reads it instead of re-deriving the wall #15813 is labelleddomain:servicesby triage); ⛔ no second PR.Clause-②: yes—needs:contract-reviewhung on both carriers with this increment.🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code