Skip to content

fix(plugin-security): a controlled_by_parent middle master is judged at the chain hop by its own direct-write composition (#23093) - #23114

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23093-cbp-two-level-chain-walk
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23093-cbp-two-level-chain-walk

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23093
Clause-②: no — a runtime authorization behaviour under the maintainer's ruling A; no published accept set or public surface moves.

What changes

@objectstack/plugin-security, assertMasterRowEditable (the master-edit legs the ADR-0055 chain walk runs on every hop). The write-RLS leg now passes one more option to computeRlsFilter:

masterGateCoversThisWrite: !delegatedWrite && this.declaresControlledByParent(rel.master),

So when the master on a hop is itself controlled_by_parent and the write is not delegated, that hop composes the master's write filter the way the master's own by-id write does (step 2.7, commit 6feac91): the platform ownership floor (owner_only_writes, positions ['org_member']) yields to the master gate. Nothing else yields. An app-authored update policy on that master and the #7665 derive-from-select floor still run, and a delegated write keeps both principals' floors. The docblock bullet that read "A master that is itself a detail keeps its floor exactly as it does today" now states the ruling. The cycle guard, the depth bound and the walk itself are untouched.

Why the vouch holds. The flag is set on the predicate (declaresControlledByParent(rel.master)) that makes the walk in assertControlledByParentWrite continue to that master's own master. That master's own master gate therefore runs next, for the same principal, on the same pass, unless the walk refuses (cycle, depth bound, broken chain). This is the same structural argument step 2.7 makes for the by-id write.

The ruling

The maintainer chose A on the decision analysis (card comment 6133315184). It is recorded twice:

  • 6133922339, the maintainer's word: 「A 延伸裁决 (推荐)」. The option text the maintainer chose:

    把 2026-08-15 裁决延伸到中间层:能改报价单的人也能改其明细(Salesforce 主从语义)。应用自定义的 update RLS 仍生效,代理写入仍保留两道底线。这是一次权限放宽,由 services 车道改一行加测试;PR feat(verify): the parent-derived write reach family, runnable as os verify --parent-write-reach (#15953 slice 4) #23091 的锁定断言随之翻成 proven。

  • 6134050293, the director binding of the maintainer's 「同意」. It also sets the fork clause: for one principal, a line write under a middle master is admitted iff that principal's direct by-id update of the middle master is admitted.

No fork was found. Every case below, at both layers, satisfies that invariant.

Pins — controlled-by-parent-chain-middle-master.test.ts (new)

The rig is a real ObjectQL with the SQL driver (better-sqlite3, in memory), the real SecurityPlugin, the real SharingService middleware, and member_default carrying the floor. Principals hold org_member. A delegator resolves from stored sys_user / sys_member / sys_user_position rows. The chain is cbc_line → cbc_quote → cbc_account (controlled_by_parent, controlled_by_parent, private). Every case asserts the quote's own by-id update beside the line writes for the same principal.

pin case quote's own update line update / insert / delete
1 middle editable a member who did not create the quote admitted, row changed admitted; row changed / row added / row gone
2 root not editable account readable, no edit grant refused refused 403 PERMISSION_DENIED, naming master cbc_account
2 root not readable account another's, never shared refused insert refused 403, naming cbc_account; update and delete refused
3 middle not editable no update grant on the quote refused refused 403, naming cbc_quote
3 middle not editable (H2) app-authored update policy stage == 'open', closed quote refused (row-level security) refused 403, naming cbc_quote (row-level security)
3 (H2, creator) same policy, the quote created by the caller refused refused (this was admitted before: see below)
3 control same policy, open quote admitted admitted, row changed
4 delegated, agent's floor floor-bound member acting for an unbound delegator refused refused 403, naming cbc_quote; the same member acting for itself is admitted
4 delegated, delegator's floor unbound agent acting for a floor-bound member refused refused 403, naming cbc_quote; the same agent acting for itself is admitted

Every refusal asserts the ADR-0112 envelope (code, status), plus the first sentence naming the caller's own object and verb: [Security] Access denied: VERB on 'cbc_line' requires edit access to its master record. Every refusal also asserts that no row moved.

Pre-fix baseline (the same file on main 8464380, before the fix): 5 failed / 4 passed. Pin 1's line update was refused (master 'cbc_quote' not editable by this user (row-level security)). Both pin-2 refusals sat at the quote hop instead of the root. The creator case of pin 3 was admitted. The non-delegated control of pin 4 was refused.

A narrowing the ruling's composition implies. Before this change, the hop composed the platform floor and an app-authored update policy as alternatives (row-level policies OR together). So the creator of a middle master could write its details while that master's own update refused them, the two-answer shape in the other direction. Under one composition the line write is refused as the quote's update is. The changeset states it for upgraders.

Ablations (each from the committed fix c6a48bd, through scripts/ablation-replace.mjs, restore proven by blob == HEAD 12a9565cd3b9 and an empty git diff HEAD)

The test imports ./security-plugin.js from source, so no dist/ is involved.

ablation mutation result
A0, the fix masterGateCoversThisWrite: false (the old leg) 5 failed / 4 passed. Pin 1: the line update expected { kind: 'refused' … } to deeply equal { kind: 'admitted' }, refused (master 'cbc_quote' not editable by this user (row-level security))
A2, root not editable the walk stops after the first hop 2 failed (both pin-2 cases). Root readable: the line write was admitted. Root unreadable: the insert passed the master gate and was refused only later by VALIDATION_FAILED
A3a, middle grant the hop skips the object-permission leg for a controlled_by_parent master 1 failed (pin 3, no update grant): the line write was admitted
A3b, middle app RLS (bare widening) the hop skips the whole write-RLS leg for a controlled_by_parent master 4 failed (pin 3 H2 and its creator case, both pin-4 cases): admitted
A4, delegation !delegatedWrite && removed from the flag 2 failed (both pin-4 cases): the delegated line write was admitted while the delegated quote update was refused

Full stack, hotcrm-shaped (scratch, not committed)

@objectstack/verify bootStack was driven through hooks.run as signed-up personas (the previous dev's matrix plus an H2 persona). Five personas ran, each with and without org_member, over chains another user owns, the persona owns, and the persona created itself, with 3 line verbs each. Measured on this fix over base 8464380: 0 cases where a line answer differs from the same persona's quote update. Results:

  • the manager: 15/15 line writes admitted, both variants;
  • root read-only: every write refused at the root;
  • middle read-only: refused naming rp_quote;
  • the app-locked manager: 30/30 line writes refused naming rp_quote (row-level security), its own door chain included;
  • the plain member: its own chains admitted; another's chain refused or 404.

Whole-repo sweep

The refusal texts requires edit access to its master and not editable by this user appear in 11 files. None pins the two-level refusal under the floor; the fixtures that arm org_member are single-level. The full plugin-security suite is green after the change. PR #23091 has merged, so its pinned expectation is flipped here, expectation only (declared in 6132790096):

  • packages/verify/src/parent-write-reach.test.ts: pwr_quote_line is now proven. The master-all-records reaches are ALL_ALLOWED with every answer written, and detail-only stays ALL_REFUSED. The summary is proven: 3, contradicted: 0, held: 135, contradictions: 0, hardFailures: 0.
  • packages/cli/test/verify-parent-write-reach.test.ts: nothing to flip. Its fixture is single-level (vpw_contract → vpw_account), so none of its expectations reads the two-level answer.

Local verification (at c6a48bd, before the merge of main; the os-dev-report on the card carries the re-run on the final head)

  • pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 193 files passed, 3981 tests passed, 45 skipped. VERDICT command-exit 0.
  • pnpm --filter @objectstack/plugin-security typecheck: exit 0. The new file is in the tsconfig.test.json program (--listFiles lists it once).
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran …: 66 derived, 66 run, 0 NOT-MEASURED, every recorded exit 0. check:i18n first answered PREREQUISITE NOT MET (exit 3) and was re-run after the CLI closure build: exit 0.
  • eslint, narrowed (--no-inline-config --format json): the 2 touched TS files, 0 errors, 0 warnings, neither ignored. This repo's eslint.config.mjs never enables type-aware linting (no parserOptions.project), so the diff cannot move a verdict on an untouched file. The full pnpm lint is CI's.

Acceptance notes

  • Docs that this change makes stale (outside this card's declared surface, not edited):

    • packages/verify/README.md, about line 136: "Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and fails the run."
    • content/docs/deployment/cli.mdx, about line 2014: the same sentence.

    After this change, both describe the old engine. Named in the report for the seat to route.

  • The RlsFilterOptions.masterGateCoversThisWrite docblock lists the call sites that vouch. The chain hop is now one of them and is documented at the call site, not in that list.

  • security/explain and the sys_attachment / sys_comment parent gates ask this same check (checkControlledByParentWrite), so they give the same answers; no separate edit.

Patch round (15058d6), appended by the domain:services seat 2 PM

The dev writes this body once, so the seat appends the dev's report text here (report 6136047254 on #23093).

Four texts that this change made false or incomplete are corrected. These are prose and comment edits only, per the claim amendment 6135431019:

  • .changeset/15953-verify-parent-write-reach-family.md, line 19: the pending parent-write-reach release note. Its sentence "Today's engine refuses every write to a detail two levels below its root master ..." now states the ruled behaviour: the write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root. It is refused otherwise, so the family reports such a chain proven. check-empty-changeset is red on this file BY DESIGN, a DELIBERATE CORRECTION of a pending release note this PR made false. It is not restored, and no changeset is added for @objectstack/verify.
  • packages/verify/README.md and content/docs/deployment/cli.mdx: the same sentence, rewritten the same way.
  • security-plugin.ts, the masterGateCoversThisWrite option docblock: its list of the call sites that vouch for coverage now includes the chain hop in assertMasterRowEditable, pointing at that call site's bullet instead of restating the reason.

Local verification at 15058d6:

  • dispatch-gates --ran: 96 derived, 96 run, 0 NOT-MEASURED. Every exit is 0 except check-empty-changeset, which exits 1 as above.
  • pnpm --filter @objectstack/plugin-security typecheck: OK.
  • The pin file and controlled-by-parent-chain.test.ts: 29 passed.
  • packages/verify parent-write-reach.test.ts: 10 passed.

At 343e21e, the code head, which this round changes only in comments:

  • plugin-security: 191 files, 3966 tests passed, 45 skipped.
  • 66 of 66 gates exit 0.

Generated by Claude Code

…at the chain hop by its own direct-write composition

Maintainer ruling A on #23093 extends the 2026-08-15 ruling to the chain
walk's middle hop: in assertMasterRowEditable, a master that is itself
controlled_by_parent, on a non-delegated write, passes
masterGateCoversThisWrite, so only the platform ownership floor yields there.
An app-authored update policy and the derive-from-select floor still run, and
a delegated write keeps both floors. A line write under a quote is now
admitted exactly when the same principal's direct update of the quote is.

Pins: controlled-by-parent-chain-middle-master.test.ts (real ObjectQL + SQL
driver, real sharing middleware, member_default floor armed).

Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE
Co-authored-by: Claude <noreply@anthropic.com>
…in hop agrees with the quote's own write

PR #23091 pinned today's answer for pwr_quote_line as contradicted. With
the chain hop judged by the middle master's own direct-write composition
(ruling A on #23093), every persona's quote-line writes agree with its
update of the quote: the object is proven and the run has no hard failure.
Expectation flips only.

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

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/verify/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/verify/README.md) — pages documenting those are invisible to this run
  • 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 — 18 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 a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b → packageMentionDocs.

Which tree this was computed on

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

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

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

…te is described as ruled, and the coverage vouch lists the chain hop

Four texts this PR's change made false or incomplete, prose and comments
only: the pending parent-write-reach release note, the verify README and
the cli.mdx family description now state that a detail under a
controlled_by_parent middle master is writable exactly when the caller may
update that middle master directly (which needs edit access to the root),
and that the family reports such a chain proven; the
masterGateCoversThisWrite option docblock lists the chain hop among the call
sites that vouch, pointing at that call site's own bullet.

Claude-Session: https://claude.ai/code/session_01FzQy26KznSa5j2gnf3AzsE
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 15058d6ff808951416948a1d83dcb99f120f8a02
Local-runs: none

Inputs, and nothing else: card #23093 (body and all 11 comments — triage 6132405210 and its amendment 6133394280, claims 6132814355 and 6134458430, dev reports 6133252394, 6135392664 and 6136047254, the decision analysis 6133315184, the maintainer's ruling A at 6133922339, its director binding at 6134050293, the claim amendment 6135431019); PR #23114 (body, its 7-file list, the net diff against the merge base with main, 501 added / 34 removed over 4 commits); the 48 check-runs on this head. Read-only: nothing built, run or re-run; no worktree.

Gate verdicts = the check-runs on this head: 39 success, 7 skipped (opt-in and filtered lanes: Console Pin Gate, Dogfood shards, Temporal Conformance, Packed-tarball smoke, and the re-fired Auto Label / Check PR Size), 2 failure — both are Check Changeset (the same job fired twice on this head, run 38328587521 and the re-run 38332224488), red by design on the DELIBERATE CORRECTION class and judged in ① below. Governed Surface Queue Guard, Lint & Repo Gates, every Type Check lane, Build Core, Build Docs, Test Core 1–6 and the card/branch/single-writer guards are green.

① Derived judgments

Accept sets and public surfaces the diff implies

  1. No file under packages/spec moves, so no accept set moves; no package index or export moves, so no public surface moves. Right. The Clause-②: no declaration with no direction arm matches, and the red job's own level-axis step read it the same way.
  2. The runtime authorization change in assertMasterRowEditable (packages/plugins/plugin-security/src/security-plugin.ts): one line of code — the write-RLS leg now passes masterGateCoversThisWrite: !delegatedWrite && this.declaresControlledByParent(rel.master) to computeRlsFilter. Judged exactly the class the ruling names, no wider:
    • What the flag does, read at the head: computeRlsFilter hands it to platformFloorYieldsToObjectWriteModel, which fires only when the flag is set AND masterGateCoversOperation holds (update/delete; the hop asks update) AND declaresControlledByParent(object) holds for that master; what it then removes is collected.filter(!isPlatformOwnershipFloorPolicy) — the platform floor policies only, ahead of the [security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored #7665 derive-from-select branch, Layer 0 untouched, app-authored policies untouched. So only the platform floor yields; an app's own update RLS and the derive-from-select floor still run — the ruling's clause, as written.
    • !delegatedWrite is read off opCtx.context.onBehalfOf, the carrier shared by both passes of a delegated write, so a delegated write keeps both principals' floors — the ruling's second clause.
    • The predicate is the walk's own continue condition (if (!this.declaresControlledByParent(masterObject)) break; in assertControlledByParentWrite), so whenever the flag lifts the floor the walk proceeds to that master's own master gate on the same pass, or refuses on the cycle guard, the depth bound or a broken chain — none of those branches is in the diff. A master that governs its own rows never sets it: the single-level case is unchanged.
    • The newly admitted set is therefore the principals whom the hop's write-RLS leg refused on the platform floor ALONE: org_member-position principals who did not create the middle master, hold update on it, and whose root legs pass — which is exactly the set whose direct by-id update of the middle master is admitted under step 2.7 (same floor, same predicate, same root gate). That is the class the binding lifts the hard stop for: organization members who can already update that middle master by id. The object-permission leg, the record-sharing leg with its authored-widener defer, the root legs, the bulk path (which never reaches this method) and the read path are untouched.
    • The composition's other direction: the creator of a middle master that an app-authored update policy refuses was admitted at the hop (floor OR policy) and is now refused, as the direct update already refused them. A narrowing for that one class, inside the ruling ("judged by its own direct-write composition"; "an app's own update RLS still applies"), required by the fork-clause invariant, and disclosed in the changeset's "Also refused now" bullet. Right.
    • security/explain and the sys_attachment / sys_comment parent gates reach the same method through checkControlledByParentWrite, so they move with it; that is one composition, not a second widening. Right.
  3. Pins — controlled-by-parent-chain-middle-master.test.ts (new): a real ObjectQL with the SQL driver, the real SecurityPlugin and SharingService middleware, member_default carrying the floor, principals holding org_member. The four ruled pins are present: pin 1 middle editable → admitted and the row changed, for update, insert and delete; pin 2 root not editable (readable, and unreadable) → refused naming cbc_account; pin 3 middle not editable → by the object grant, by an app-authored update policy, and that policy's creator case, with an open-quote control; pin 4 delegated writes keep both floors, agent-side and delegator-side, each beside an undelegated control. Every case asserts the quote's own by-id update next to the line writes — the fork clause's invariant, pinned case by case. Right. A new sibling file rather than controlled-by-parent-chain.test.ts is justified: that rig's principals hold no position, so the floor never binds there. The refusal-pin ablations (A2, A3a, A3b, A4) and A0 are on the PR body and report 6135392664; each maps to a refusal pin and none is missing; not re-run here (read-only).
  4. packages/verify/src/parent-write-reach.test.ts: expectation flip only — pwr_quote_line contradicted → proven; summary proven 3 / contradicted 0 / held 135 / hardFailures 0; detail-only stays ALL_REFUSED. PR feat(verify): the parent-derived write reach family, runnable as os verify --parent-write-reach (#15953 slice 4) #23091 landed first (its file is on the merge base), so by the declared order (6132790096) this PR flips it. Right. packages/cli/test/verify-parent-write-reach.test.ts is untouched: its fixture is single-level (vpw_contract under vpw_account), verified at the head; nothing to flip. Right.
  5. Docblocks: the RlsFilterOptions.masterGateCoversThisWrite call-site roster gains the chain hop, pointing at the leg's own bullet; the leg's bullet that read "A master that is itself a detail keeps its floor exactly as it does today" now states the ruling. Comments only; the roster of vouching call sites is complete again. Right.
  6. Published prose — packages/verify/README.md (ships in the tarball) and content/docs/deployment/cli.mdx — judged sentence by sentence under the correction below.

DELIBERATE CORRECTION — confirmed. Check Changeset is red on this head because the PR edits .changeset/15953-verify-parent-write-reach-family.md, the pending release note of @objectstack/verify (minor) and @objectstack/cli (minor) that PR #23091 added and this PR did not. The gate's log names the file and the class ("DELIBERATE CORRECTION — your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it — say so on the PR and get it confirmed"). The diff to that file is ONE sentence (line 19, one line added, one removed); nothing else in it moves, nothing is restored, renamed or deleted; no skip-changeset label is on the PR, correctly, since the note is a pending release. The same job ran its later steps past the red and they passed: ADR-0087 disposition (no declared-breaking changeset among the two it saw), no major, level axis read as clause-② no. This record is the written confirmation, sentence by sentence:

  • The changeset, line 19. OLD: "Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and the run fails." Made false by this PR: at this head the hop judges the middle master by its own direct-write composition, so a caller who edits the middle master is admitted (pin 1; and pwr_quote_line proven with 0 contradictions in the family's own test), and such a chain no longer fails the run. NEW: "A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain proven." Truthful on this head: "admitted exactly when … may update the middle master directly" is the fork-clause invariant the pins hold in every case (admitted: pin 1 and the pin-3 control; refused: root not editable, root not readable, middle grant missing, app policy refusing, the creator under that policy, both delegated cases — each beside a matching direct quote update); "which also needs edit access to the root" is true because the middle master's own by-id update runs its master gate on the root (pin 2 refuses there, naming cbc_account); "reports such a chain proven" is the flipped expectation in parent-write-reach.test.ts. Confirmed; do not restore.
  • packages/verify/README.md, the twin. OLD: "Today's engine refuses every write to a detail two levels below its root master, even where the caller edits the middle master, so on an app that declares such a chain the family reports a contradiction and fails the run." Made false by this PR, on the same grounds. NEW: "A detail two levels below its root master is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain proven." Truthful on this head, on the same grounds; it reads correctly in its paragraph (after the unattributed-refusal and system-door sentences, before the exit-code paragraph, which still names contradicted / failed as the family's failures). No separate @objectstack/verify changeset is owed for this line: it documents the still-pending --parent-write-reach family, whose own release note is the corrected 15953 entry, shipping in the same release. Confirmed.
  • content/docs/deployment/cli.mdx, the twin. OLD: "Today's engine refuses every write to a detail two levels below its root master (a line under a quote under an account), even where the caller edits the middle master, so an app that declares such a chain fails the run." Made false by this PR, on the same grounds. NEW: "A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so such a chain is reported proven." Truthful on this head, and consistent with the paragraph's earlier sentence that the master-edit half is measured "by an empty update of the master as the caller, which runs every write gate the master has — for a master that is itself controlled by its parent, the gate above it too". Build Docs, Check Documentation Links and the docs & code guards are green on the head. Confirmed.

None of the three new sentences carries a tracker number, and none claims more than the engine does at this head.

② Semver level

.changeset/23093-cbp-middle-master-floor.md declares @objectstack/plugin-security: patch, carries Clause-②: no in its body, and the PR body's line reads Clause-②: no — a runtime authorization behaviour under the maintainer's ruling A; no published accept set or public surface moves. This matches what the diff publishes: a fix in a released package that moves no public surface and no accept set is patch (the level follows the act; no index, key or value widened), the director binding itself prescribes "a patch changeset", and the nearest precedent — commit 498f4e8, leg 1's floor drop at this same leg — took patch. Not breaking: no key or export is removed, so no migration text and no ADR-0087 marker is owed, and the ADR-0087 step passed on the head. The body states for upgraders the admitted class, the still-refused classes, the one narrowing ("Also refused now") and the unchanged classes — an adequate release note. @objectstack/verify and @objectstack/cli release nothing of their own here beyond the pending 15953 note, corrected above; no skip-changeset. Right.

③ Boundary flags

  • Dev report 6133252394 (round 3). Open question (extend the 2026-08-15 ruling to the hop: A / B / C) → ruled A at 6133922339, bound at 6134050293. Out-of-scope 1 (the card's grade rested on a regression that was not one) → triage re-graded at 6133394280: p1 stays, target:v18 off. Out-of-scope 2 (PR feat(verify): the parent-derived write reach family, runnable as os verify --parent-write-reach (#15953 slice 4) #23091's framing) → feat(verify): the parent-derived write reach family, runnable as os verify --parent-write-reach (#15953 slice 4) #23091 merged; its expectation is flipped here under the declared order. Out-of-scope 3 (hotcrm pins a @objectstack/verify whose bootStack ran organization-less personas) → carrier hotcrm, outside this repository; nothing for this PR; stands as noted.
  • Dev report 6135392664 (round 4). Open question (who rewrites the README and cli.mdx sentences) → A, by claim amendment 6135431019, done in the patch round at this head. Out-of-scope (the masterGateCoversThisWrite call-site roster) → done at this head. Deviations: the new sibling pin file (justified above; inside the claim's "tests for this gate and neighbours"); origin/main merged in (clean; the merge base is on main); the body's verification numbers predate that merge → the seat's appended "Patch round" section carries the head's numbers, and the check-runs on the head are the gate verdicts in any case; commit trailers in AGENTS.md's form; the cli test untouched (verified single-level). All answered.
  • Dev report 6136047254 (patch round). No open questions. Deviations: the PR body not PATCHed by the dev → appended by the seat and read back in the body; the worktree recreated and removed → housekeeping. Its gate reading (96 run, only check-empty-changeset red) agrees with the check-runs on the head.
  • File surface. The seven files equal claim 6134458430 plus amendment 6135431019 exactly; no breach to explain.
  • Fork clause (6134050293). No case at either layer where a line write is admitted while the same principal's direct middle-master update is refused: the pin file asserts the pair in every case, the dev reports none, and none is visible in the diff.
  • Governance. No governed surface in the file list (Governed Surface Queue Guard green). This record is the confirmation of the deliberate correction, not a Tier S landing record. Nothing to escalate.

Implemented-by: claude/issue-23093-cbp-two-level-chain-walk
Reviewed-by: session_01FzQy26KznSa5j2gnf3AzsE

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Designed red: Check Changeset on 15058d6ff (DELIBERATE CORRECTION), confirmed

domain:services seat 2 · session_01FzQy26KznSa5j2gnf3AzsE · 2026-10-13T01:37Z. This PR enters the queue with this one red, under the three conditions for a red by design:

  1. The gate's source says the red is by design on the pushed branch. scripts/check-empty-changeset.mjs ("Past a designed red"): "Two steps of this job are red BY DESIGN on the DELIBERATE CORRECTION class, and the red is left standing". The job's log on this head: "DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed".
  2. It does not run on merge_group. Check Changeset is the changeset-check job of .github/workflows/pr-automation.yml, whose only trigger is pull_request.
  3. This comment records the gate and the reason.

Every other check on this head is green or an expected skip. Not restored, and no skip-changeset.


Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants