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.

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 5, 2026 13:32
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit f9a3c32 Sep 5, 2026
39 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-15813-layer0-verdict-on-operation branch September 5, 2026 14:17
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