Skip to content

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

Merged
zhuangjianguo merged 6 commits into
mainfrom
claude/issue-15813-layer0-verdict-on-operation
Sep 5, 2026
Merged

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
zhuangjianguo merged 6 commits into
mainfrom
claude/issue-15813-layer0-verdict-on-operation

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #15813

Implements the seam ruled on #15706 (5549576962, option (i)): plugin-security records 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

the verdict recorded must be what the wall decided, not a re-statement of its inputs. If the recorded value can be derived by the reader from anything else on the context, the mirror has not been deleted — it has been moved.

bulkEventOrganizationId(recordedVerdict: unknown) (packages/objectql/src/engine.ts) has ONE input: the value the plugin recorded. Its body is a TenantLayer0VerdictSchema.safeParse and two kind reads; it names no posture, no tenantId, no accessible_org_ids, no rung, no schema. The recorded value is computeTenantLayer0Verdict(...)'s result — the same call that produces the injected predicate (computeTenantLayer0Filter is now tenantLayer0FilterOf(computeTenantLayer0Verdict(input))), so it cannot be re-derived from anything else on the context: its deciding inputs include the deployment's platformGlobalObjects carve-out (#12699) and the capability probe, neither of which the context carries. The engine's enforcedTenancyPosture() accessor, its imports of carriesTenantScopeColumn / AuthzPostureSchema / postureEnforcesWall / postureUsesUnionScope, and the module-level export of carriesTenantScopeColumn (registry.ts) are all deleted — code-only counts in engine.ts: 0 / 0 / 0 / 0 / 0. The mirror is deleted, not moved.

What changed

package change changeset
@objectstack/spec new export TenantLayer0VerdictSchema / TenantLayer0Verdict (security/tenant-layer0-verdict.ts): none · organization · organizations (a deduplicated, non-empty set) · deny; .strict(), junk fails to parse minor
@objectstack/objectql OperationContext.tenantLayer0Verdict?: TenantLayer0Verdict (additive member on a published surface); publishBulkDataEvent reads it via bulkEventOrganizationId; both call sites pass opCtx.tenantLayer0Verdict; the #15687 mirror (enforcedTenancyPosture, the posture/context/schema derivation) removed minor
@objectstack/plugin-security tenant-layer.ts: computeTenantLayer0Verdict, tenantLayer0FilterOf, intersectTenantLayer0Verdicts; computeTenantLayer0Filter becomes the projection. security-plugin.ts: computeLayeredRlsFilter returns the verdict beside the layers (the #12974 owner read-bypass returns none); step 3 (the AST injection, reads and predicate writes) composes from the split with the same andComposeLayers — the injected predicate is byte-identical — and records opCtx.tenantLayer0Verdict; an on-behalf-of operation records the intersection of the caller's and the delegator's walls minor

Recorded 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 from none, and both read as "not asserted".

Populations that move, and in which direction

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; deny spreads 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 records none with a firing control on the sibling object; PLATFORM_ADMIN on public → organization, on private → none; rungless probe; deny on an org-less delete; nothing recorded for a system context or a by-id write; getReadFilter byte-identical.
  • packages/plugins/plugin-security/src/tenant-layer0-verdict-end-to-end.test.ts (new, 3) — real ObjectQL + SqlDriver + real SecurityPlugin + captured realtime: the walled object publishes org_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: publishBulkDataEvent does not stamp the batch organizationId the spec now declares (PR #15218) — the bulk producer half of the #13566 p0 cross-tenant webhook leak #15225 block RETARGETED (see below): a recorded organization stamps the verdict's organization while tenantId says another; one-member organizations present, two absent; none / deny absent; no recorded verdict ⇒ absent under isolated + a member with an active organization (the mirror-deleted pin); six junk shapes absent-and-still-published; the source pin now also asserts bulkEventOrganizationId's body names TenantLayer0VerdictSchema.safeParse( and none of tenantId / accessible_org_ids / posture / isSystem / carriesTenantScopeColumn / getObject( / enforcedTenancyPosture / resolveEnginePosture.

Zone 2 assumptions — one falsified, stated plainly

Out of scope, filed

Docs

content/docs/permissions/system-context.mdx row 30 re-anchored from the deleted engine isSystem read 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 cad1395fa

All figures below are from runs at cad1395fa (the merge of origin/main at 59953d5a3), exit codes captured before any pipe, verdict lines quoted from each gate's own output.

  • build: pnpm --filter @objectstack/spec buildVERDICT command-exit 0; pnpm --filter @objectstack/spec check:generated✓ All 15 generated artifacts are up to date.; pnpm --filter @objectstack/objectql buildVERDICT command-exit 0 (dist reader present: TenantLayer0VerdictSchema.safeParse ×1; carriesTenantScopeColumn( code sites in dist: 2, both registry-internal).
  • typecheck: @objectstack/spec, @objectstack/objectql, @objectstack/plugin-security → each VERDICT command-exit 0.
  • tests (all via scripts/pm/os-verify-lock.sh, --maxWorkers=2): objectql engine-data-events.test.ts 41/41 (16 in the retargeted block) + engine-middleware-operation-vocabulary.test.ts 5/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); spec tenant-layer0-verdict.test.ts + tenancy-posture.test.tsTests 37 passed (37).
  • derived gates (node scripts/pm/dispatch-gates.mjs --commands, no paths, derived at cad1395fa: 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 — needs pnpm build of 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-examples needed @objectstack/client-react built first and then answered ✅ 257 prose examples type-check. check:system-context-censusOK — 106 elevation read sites in 20 packages across 45 files, all anchored.
  • lint, narrowed with the three evidence pieces: ① the population read from eslint.config.mjs itself (8 config entries, 5 global-ignore entries, no projectService/parserOptions.project — type-aware linting is off, so this diff cannot move any untouched file's verdict); ② eslint --no-inline-config --format json over the 11 touched lintable files → 11 files linted, 0 errors, 0 warnings, exit 0; ③ config unmoved in the incoming merge range.
  • comment-mask / cross-package-test-inputs / nul-bytes / engine-double-contract: all in the derived list, all green.

Execution terms from the ruling

🤖 Generated with Claude Code

https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ


Generated by Claude Code

…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

# 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
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec, touching 18 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/declaration-map/security.json, packages/spec/export-origins/security.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via OperationContext (symbol, a top-level interface))
  • content/docs/deployment/environment-variables.mdx (via PLATFORM_ADMIN (literal, a string literal in bulkEventOrganizationId))
  • content/docs/permissions/authorization.mdx (via PLATFORM_ADMIN (literal, a string literal in bulkEventOrganizationId))
  • content/docs/permissions/field-level-security.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/index.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/permissions/permission-sets.mdx (via PLATFORM_ADMIN (literal, a string literal in bulkEventOrganizationId))
  • content/docs/permissions/permissions-matrix.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/sharing-rules.mdx (via computeLayeredRlsFilter (symbol, a method of class SecurityPlugin))
  • content/docs/permissions/system-context.mdx (via OperationContext (symbol, a top-level interface))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/releases/v15.mdx (via PLATFORM_ADMIN (literal, a string literal in bulkEventOrganizationId))
  • content/docs/releases/v16.mdx (via PLATFORM_ADMIN (literal, a string literal in bulkEventOrganizationId))
  • content/docs/releases/v17.mdx (via computeTenantLayer0Filter (symbol, a top-level function), PLATFORM_ADMIN (literal, a string literal in bulkEventOrganizationId))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/declaration-map/security.json, packages/spec/export-origins/security.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 66 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 133 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 99a5bc674bcc3b5846303f5d7abcc5331574736cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e823af146e67ecf69a2cb042249fc7fde88e4e0c — the merge of head 2165bc8b7bdd922ed362c79fc41729e656afc9e6 into base 99a5bc674bcc3b5846303f5d7abcc5331574736c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 99a5bc674bcc3b5846303f5d7abcc5331574736c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants