Skip to content

fix(plugin-security): explain composes a lifecycle verb's row-level security as the door does - #22570

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22550-lifecycle-verb-rls-mapping
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22550-lifecycle-verb-rls-mapping

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22550
Clause-②: no

POST /api/v1/security/explain now composes a lifecycle verb's row-level security for the operation the door composes it for: transfer and restore as update, purge as delete. Before, explain composed the raw verb, which RLSCompiler.mapOperationToRLS sends to select, so no update-class policy and no ownership floor reached a transfer's verdict.

The mapping is declared once, in a new module-internal helper (lifecycle-verb-rls-operation.ts, rlsOperationForVerb). It is not exported from index.ts, so Clause-②: no and the changeset is patch. Read on origin/main 4638625e0; line numbers below are at this PR's head.

The measurement (before / after)

Before, on the card's own shape (the dogfood pin below, against the raw-verb composition; this is ablation A): a public_read_write cpg_board row named closed, which an app-authored update policy (name == 'open') excludes, for an org_member holding edit and transfer.

request before after
PATCH /data/cpg_board/ID writing owner_id (the transfer door) 403 PERMISSION_DENIED 403 PERMISSION_DENIED (unchanged)
explain transfer, record visible: true, decidedBy: 'object_crud' visible: false, decidedBy: 'rls' (equal to explain update)
explain transfer, rls layer's record not_evaluated ("No business RLS policy applies") excluded
control: the open board, PATCH owner_id 200 200
control: explain transfer on the open board, rls record not_evaluated admitted, visible: true

The per-verb enumeration: the door's composed operation and explain's

verb the door composes RLS for explain before explain after
transfer update: step 2.7, security-plugin.ts:3176 (rlsOperation) into computeRlsFilter :3209. The live door is the PATCH writing owner_id, an update the raw verb, so select (rls-compiler.ts:1238 default) update
restore update, same site. The object gate refuses it to every principal first (permission-evaluator.ts DESTRUCTIVE_OPERATIONS), so it never reaches step 2.7 today select update
purge delete, same site, same object-gate refusal select delete
read / export find find find (unchanged)
create / update / delete themselves themselves themselves (unchanged)

explain reads the helper at explain-engine.ts:1814 (rlsOp) and uses it for both of its RLS compositions:

  • the record's row story, computeLayeredRlsFilter at :1372;
  • the object-level rls layer, computeRlsFilter at :2186 (and the delegator leg at :2195).

Every other row judgement keeps the verb as asked: the sharing write gate (:1489), the master-detail check (MASTER_CHECKED_BY_ID_WRITES) and its prose, and fetchRecord.

What changed

  • lifecycle-verb-rls-operation.ts (new). LIFECYCLE_VERB_RLS_OPERATION (frozen: transfer to update, restore to update, purge to delete) and rlsOperationForVerb. Every other verb is returned unchanged, including names an object literal inherits (constructor, toString).
  • security-plugin.ts, step 2.7. const rlsOperation = rlsOperationForVerb(opCtx.operation) replaces the inline ternary. It is byte-identical in behaviour, pinned over 16 operations against the old expression verbatim.
  • security-plugin.ts, step 3 (about :4279). Comment only. The verb stays RAW there by design, under the invariant packages/objectql/src/engine-middleware-operation-vocabulary.test.ts pins. The comment now names rlsOperationForVerb as the mapping that site adopts if the invariant ever breaks. No behaviour change.
  • security-plugin.ts, explain wiring (recordWriteFloorOptions, :5467). Comment only. It now receives the mapped operation, so a transfer arrives as update.
  • explain-engine.ts. rlsOp is computed once and passed to both compositions. The deps docblocks say what operation the two RLS deps are asked for.
  • .changeset/22550-explain-lifecycle-verb-rls-operation.md: @objectstack/plugin-security patch.

mapOperationToRLS's default is untouched (triage: it serves the compiler's other callers).

PM mechanism assumptions, measured

  1. Holds. Step 2.7's inline ternary was the only lifecycle-verb to RLS mapping on the door. A grep of packages/ non-test sources for the lifecycle comparisons hits only security-plugin.ts:3174–:3175 before this change. Byte-identity is pinned in lifecycle-verb-rls-operation.test.ts.
  2. Holds, with one addition. The record path's computeLayeredRlsFilter(..., engineOp, ...) was the cause of the record verdict. explain also composed the object-level rls layer for the raw verb (:2186/:2195), so both now read the helper.
    • The floor-drop and the vouch key on the mapped verb on both sides. The door passes rlsOperation to resolvePreImageFloorDrop and computeRlsFilter, and masterGateCoversOperation reads it. explain hands its dep rlsOp, and recordWriteFloorOptions and masterGateCoversOperation read that.
    • Evidence: the dogfood cell "transfer: the master's editor, who did not create the child, is explained writable and the door answers 200" stays green. After the mapping, that cell has the controlled_by_parent update floor in play, handed over by the vouch on update. Before, it never applied to a transfer at all.
  3. Holds. restore and purge are refused at the object gate for every principal, and explain says object_crud for them. To reach the door's step 2.7 composition, the unit pin lifts only that gate, the way the lifecycle batch's rows will: restore asks the update grant and purge the delete grant. Every row-level input is left as it is. A separate cell shows the gate standing, with the same composition under it.

Pins

pin where proves
Per verb × row, door beside explain explain-write-verdict-inputs.test.ts:532, a new describe at :518 LIFECYCLE_STEWARD's update policy admits only Shared and its delete policy only Owned, so the admitted rows name the composed class. For each of transfer / restore / purge, on the row its class excludes and on the one it admits: the by-id verb through the real security and sharing middleware (step 2.7) admits or refuses (403 PERMISSION_DENIED), and explain's record and its rls record outcome match it
Object-level layer same file :558 On an object with no tenant column, read composes nothing (not_applicable) and each lifecycle verb's object-level rls layer narrows with its class's policy. On the walled object that verdict is narrows for every operation (the tenant predicate), so it cannot tell the classes apart
The live transfer door same file :572 the update writing owner_id: 403 on Owned, ownership untouched; 200 on Shared, ownership moved. explain transfer matches both
Grants retired same file :592 restore / purge: explain object_crud, the object gate 403, and the rls record already reports the update / delete class
Mapping, byte-identical lifecycle-verb-rls-operation.test.ts 16 operations against step 2.7's old inline expression verbatim. The declared key set is exactly the three verbs
The card's row, end to end packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts:372 the cpg_board rows above, through better-auth members, the REST explain route and /data. The board steward's set and its assertArmed declaration are added to the fixture's boot

Ablations

Each leg went through scripts/ablation-replace.mjs: the anchor hit once and went 1 to 0, and the blob changed. The restore was proven as blob equal to HEAD with an empty git diff HEAD, under an outer trap ... EXIT INT TERM, and git status --porcelain was empty afterwards. The unit suites import src/, so they need no build. For the dogfood leg, plugin-security was rebuilt, and scripts/ablation-dist-preflight.mjs read the marker in both runtime bundles (dist/index.js, dist/index.mjs) before the run. After the restore it was rebuilt, and the preflight with --absent read the marker absent from all 6 built files, with the tree clean. The unit legs ran from the committed head 1465c4928.

ablation mutation predicted observed
A: the record path composes the raw verb explain-engine.ts:1372: rlsOp back to engineOp every lifecycle record cell red; dogfood board cells red unit 9 red / 47 green (the 6 per-verb cells, the live-door Owned cell, both grants-retired cells). Dogfood 2 red / 13 green: the closed board read visible: true, decidedBy: 'object_crud' beside the door's 403, which is the card's measurement. The open board's rls record read not_evaluated
B: the object-level layer composes the raw verb :2186: rlsOp back to dataOp the three object-level cells red, nothing else unit 3 red / 53 green: expected 'not_applicable' to be 'narrows' per verb
C: the door's step 2.7 composes the raw verb security-plugin.ts:3176: rlsOperationForVerb(opCtx.operation) back to opCtx.operation the door half of the pin can fail: each excluded-row cell red on the door unit 3 red / 53 green: transfer × Owned, restore × Owned, purge × Shared, each "the by-id VERB through the middleware (written): expected true to be false"

The mutated build in leg A failed its DTS step (TS6133: 'rlsOp' is declared but its value is never read). The JS bundles were emitted, and the preflight above confirmed the marker in both before the dogfood run read them.

Verification

Run at the merged head 47ff3aee7 (origin/main 6a3f82efa merged in; nothing there touches plugin-security), on a shared box:

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 193 files, 4064 passed, 45 skipped.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0 (tsc --noEmit, the scripts project, and check:test-typecheck: the test layer compiles, 0 debt).
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/cbp-explain-master-write.dogfood.test.ts: 15 passed (13 before this PR, plus 2).
  • pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 71 commands at 47ff3aee7. All 71 were run serially, each exit code captured before any pipe, and all 71 exited 0. --ran reconciliation: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: every line records its exit code). The derivation's CI-only families (shard attestations, path-scheduled jobs, the workspace type-check lanes) are NOT MEASURED here; they are CI's.
  • Lint, narrowed: eslint --no-inline-config --format json on the 6 changed .ts files, at 47ff3aee7: 6 files, 0 errors, 0 warnings, none reported ignored. The population is read from eslint.config.mjs itself. Invariance: that config enables no type-aware linting (no parserOptions.project, eslint.config.mjs:327), so this diff cannot move any untouched file's verdict. The repo-wide pnpm lint is CI's.

Acceptance notes

  • Out of scope, reported for the seat (measured): explain transfer does not answer the read question the transfer door asks. Measured on this branch at 1465c4928, with a scratch dogfood probe on the cpg fixture that was deleted after the run. The board steward's set had a select policy name == 'open' and an update policy name != 'nothing', on a board named closed:

    • GET and PATCH owner_id both answer 404 RECORD_NOT_FOUND;
    • explain update answers { visible: false };
    • explain transfer answers visible: true, decidedBy: 'rls'.

    The read-absent rewrite in explainAccess (explain-engine.ts:2254) keys on operation === 'update' || operation === 'delete'. It is a separate mechanism from this card's composition, so it is not changed here.

  • Dormant, not changed: explain's sharing write gate for purge is canEdit (:1489 keys on engineOp === 'delete'). The door's step 2.7 asks the sharing service's delete verdict for a purge's floor decision (resolveSharingWriteVerdict('delete')), and plugin-sharing's own middleware gates only update / delete. Nobody can purge today (the object gate), and it is not an RLS composition, so it is left for the batch that returns the grant.

  • Read-only inference, not measured: the object-level rls verdict recognises a deny-all sentinel only at the top of the composed filter (isDenyAll). On a walled object, andComposeLayers nests a business-RLS deny under $and with the tenant predicate, so that layer may report narrows (and allowed: true) where business RLS denies every row. The record path judges Layer 1 on its own and is unaffected.


Generated by Claude Code

…ecurity as the door does

The by-id write pre-image gate (step 2.7) composes transfer and restore as
update and purge as delete. explain composed the raw verb, which the RLS
compiler sends to select, so a transfer was explained against no
update-class policy and no ownership floor.

The mapping now lives once, in lifecycle-verb-rls-operation.ts. Step 2.7
reads it in place of its inline expression (byte-identical), and explain
reads it for both of its RLS compositions: the object-level rls layer and
the record's row story.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…he transfer door

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…nwalled object

The walled object's object-level rls verdict reads 'narrows' for every
operation (the tenant predicate), so it cannot tell the composed class
apart; an object with no tenant column can.

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 8 documentable anchor(s).

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

  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a method of class SecurityPlugin))
What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 16 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 fbb065fd4b52dce103776e49fefd90f0138059a7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f3dbd9fd0c99d94ad016b80c2e2a059ae96d2f4f — the merge of head 47ff3aee73861de4382d56e813500d0b8b31738d into base fbb065fd4b52dce103776e49fefd90f0138059a7, 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 f3dbd9fd0c99d94ad016b80c2e2a059ae96d2f4f && git checkout f3dbd9fd0c99d94ad016b80c2e2a059ae96d2f4f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbb065fd4b52dce103776e49fefd90f0138059a7 47ff3aee73861de4382d56e813500d0b8b31738d && git checkout -B drift-repro fbb065fd4b52dce103776e49fefd90f0138059a7 && git merge --no-ff 47ff3aee73861de4382d56e813500d0b8b31738d

node scripts/docs-audit/affected-docs.mjs --json fbb065fd4b52dce103776e49fefd90f0138059a7

⚠️ 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 fbb065fd4b52dce103776e49fefd90f0138059a7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 02:00
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 02:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 25be876 Oct 10, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22550-lifecycle-verb-rls-mapping branch October 10, 2026 02:22
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/m tests tooling

Projects

None yet

2 participants