Skip to content

fix(service-storage,plugin-audit,plugin-security)!: the attachment and comment parent gates judge a controlled_by_parent parent through its master - #22513

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-22455-attachment-gate-master-write
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-22455-attachment-gate-master-write

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #22455
Clause-②: no (narrowing)

The sys_attachment parent gate (@objectstack/service-storage) and the sys_comment moderation gate (@objectstack/plugin-audit) now judge a controlled_by_parent parent through its master: the answer the parent's own by-id update gets. @objectstack/plugin-security serves the master-detail write check that card #22464 declared (ISecurityService.checkControlledByParentWrite) from the write path's own composition, and both gates ask it when the sharing service abstains. This executes seat 1's ruling A (6079158667) as carried by triage (6079448762) and the claim 6083853580.

Reproduction (before)

Real stack: bootStack, org-bound, with StorageServicePlugin and AuditPlugin. The fixture is packages/qa/dogfood/test/fixtures/cbp-parent-gates-fixture.ts. A member holds org_member, the fixture's read/create/edit baseline, and a set granting sys_attachment and sys_comment create and delete. The master cpg_account is public_read, so the member reads it, and it is owned by the admin. cpg_contract is a controlled_by_parent child of it. cpg_vault_item is a controlled_by_parent child of a private admin-owned master. Measured with the new dogfood file on the unmodified base e148ca984 (test commit bca810323):

Request by the member Before
PATCH /data/cpg_contract/ID (precondition) 403 PERMISSION_DENIED
POST /data/sys_attachment on that cpg_contract 201 (file attached)
POST /data/sys_attachment on cpg_vault_item, which answers the member 404 201
DELETE /data/sys_attachment/ID of the admin's file on the cpg_contract 200 (row gone)
DELETE /data/sys_comment/ID of the admin's comment on the cpg_contract 200 (row gone)
security.checkControlledByParentWrite not served (undefined)

Controls, green before and after: the master owner attaches (201); the member attaches under a master they own (201); a public_read_write parent admits the member (201); an uploader deletes their own file (200); an author deletes their own comment, and the master owner moderates a member's comment (200).

What changed

  • plugin-security: serves checkControlledByParentWrite(object, recordId, context) on the typed service literal, so the compiler holds it to the contract.
    • It runs assertControlledByParentWrite for the principal's sets, then for the delegator's on an on-behalf-of context (step 2.8's two calls, in order). The first refusal is the answer.
    • Before that it runs the engine middleware's context prologue: principal-less refusal, permission-set resolution failing closed, the missing-delegator refusal and delegator resolution. To keep one composition, that prologue moved out of the middleware into resolveOperationPrincipals, which the middleware and the member both call. Its refusals, their order and their wording are unchanged.
    • Outcomes are read off what the check throws. Each master-edit refusal is tagged with its leg in a WeakMap beside the error, never on it, so the write path's envelope is byte for byte the same. The three non-verdict errors map to the unresolvable reasons. Anything else (a store fault, a refusal of the context) rejects unchanged.
    • There is no second copy of the master-detail check: the member is the check.
  • service-storage: one composition, mayEditParent, answers parent EDIT for all four limbs: attach, the row rule on update and delete, and the re-point's attach rule. It reads the tri-state checkEdit, not canEdit. The installer takes an optional trailing security resolver, and StorageServicePlugin wires it.
  • plugin-audit: canEditParent gets the same composition. It is the one function behind the moderation limb, so it covers comment delete (which reproduced) and comment update. Insert and re-point still ask READ.
  • AttachmentSharingLike / CommentSharingLike now Pick checkEdit. New port types AttachmentSecurityLike and CommentSecurityLike are Picks of ISecurityService.
  • content/docs/permissions/system-context.mdx: one census row (9b), anchored at security-plugin.ts#checkControlledByParentWrite, plus the census gate's own --fix counts (121 to 122) and nothing else on the page. The reason: the served member answers allow for a system context (the contract's first bullet), which is a new ExecutionContext.isSystem read site, and check:system-context-census requires every such site to carry a row. The seat authorized this one row by amending the claim (6083853580), declared to domain:devx.

Gate truth table (both gates, every limb)

checkEdit checkControlledByParentWrite Gate
allow not asked admit
deny not asked refuse, today's envelope
abstain allow admit
abstain not_applicable admit (the parent derives nothing from a master: the public_read_write control)
abstain deny (any leg) refuse, today's envelope
abstain unresolvable (broken declaration, missing record, null master reference) refuse, today's envelope (fails closed)
abstain rejects (datasource fault, refused context) propagates unchanged; a fault keeps its 503
abstain member absent (no security service, or one that does not serve it) admit: today's answer, and the parent's own update meets no master check either
no sharing service n/a degraded mode: caller-scoped parent read, unchanged

Envelopes: attach and re-point answer 403 ATTACHMENT_PARENT_ACCESS. A delete of another user's file answers 403 ATTACHMENT_DELETE_DENIED, and an update of it answers 403 RECORD_NOT_ACCESSIBLE. Comment delete or update answers 403 RECORD_NOT_ACCESSIBLE. A caller who cannot read the parent still gets the not-visible 403 PERMISSION_DENIED.

On not_applicable: the gates ask the member on every abstention. The member is the one place that decides whether a parent is controlled_by_parent, so the gates carry no second copy of that predicate. not_applicable is therefore exactly the "any other abstain admits" arm of the ruling, and it matches the contract's consumer rule (proceed on allow and not_applicable). The claim's line first read not_applicable as fail-closed. That reading only arises if the gate decides "this parent is controlled_by_parent" itself, which this design never does. The seat accepted this reading and corrected the claim line.

Pins and ablations

Every ablation went through scripts/ablation-replace.mjs: the anchor hit once, the blob changed, and on restore the blob equals HEAD with git diff HEAD empty.

Pin Where Ablation Reading
Truth table (9 rows), member asked with the gate's envelope, 503 propagation, kernel without the member, delete / not-visible / update / re-point limbs service-storage attachment-access-hooks.test.ts if (verdict === 'allow') return true; becomes if (verdict !== 'deny') return true; (the canEdit fold) 12 failed / 63 passed; restored to blob c078a57acb10 = HEAD
The same for the moderation limb plugin-audit comment-access-hooks.test.ts same mutation 11 failed / 56 passed; restored to blob 7718798ea3e2 = HEAD
Real stack: both attaches, the delete of another's file and the comment delete refused; the controls admit; PATCH parity dogfood cbp-parent-attachment-comment-gates.dogfood.test.ts both gates stop consulting the master check, so an abstention admits; rebuilt; ablation-dist-preflight found the marker in 2 built files of each package 4 failed / 6 passed (expected 201 to be 403 twice, expected 200 to be 403 twice). Restore leg: sources equal HEAD, rebuilt, --absent passes with a clean tree, rerun 10 / 10
Member parity table, served pin plugin-security controlled-by-parent-write-member.test.ts, registered-security-service-members.pin.test.ts A: member not served. B: mirror step 2.8's userId guard. C: refusals carry no leg A 11 failed / 3 passed. B 2 failed (the guest pin and the principal-less pin) / 12 passed. C 4 failed / 10 passed. Each restored to blob 8b1ac155b333 = HEAD

Three earlier dogfood ablation attempts were not measurements, and none of them reached a test. Two mutations failed the declaration build (a narrowing error, then an unused parameter), and one was refused by ablation-replace because the replacement contained the anchor. Each attempt restored the sources to HEAD.

PATCH parity

  • Plugin level (controlled-by-parent-write-member.test.ts): the real engine middleware's by-id update and the served member run on one store for one caller.
    • allow for exactly the records the update admits: an owned master, a master shared at edit, a master admitted by authored write RLS, and a two-hop chain.
    • Each deny leg (object_permission, row_level_security, record_sharing) matches the reason step 2.8's refusal states. The delegator leg is covered, and so is master_chain (an empty master reference above the first hop).
    • The three unresolvable reasons each meet a refused update. A system context answers allow, and a non-controlled_by_parent object answers not_applicable.
    • A principal-less context and a missing delegator reject with the write path's own refusal message.
  • Real stack: the member's PATCH of the child answers 403, and its message says requires edit access to its master record (master 'cpg_account' not editable by this user (row-level security)). The member answers { outcome: 'deny', leg: 'row_level_security' }.
    • The leg is the master's write RLS, not record sharing: the platform's created_by ownership floor binds org_member on the master, and record sharing gives the member no basis to lift it. My first expectation was record_sharing, and the measurement corrected it.
    • The owner gets allow and a 200 PATCH. The member under their own master gets allow and a 200 PATCH. The public_read_write board gets not_applicable.

The two serving notes (landing record 6083231669)

  1. Step 2.8's context.userId guard is NOT mirrored.
    • Mirroring it would answer allow for a principal whose master nothing measured. The contract's allow covers only a system context or every leg passing, and this lane does not edit the spec TSDoc.
    • Not mirroring it can only refuse: every non-allow outcome refuses. The record's own update already refuses such a principal at the object-level gate, because guest bindings refuse allowEdit, so the overall parity holds.
    • Pinned (a positions-only context answers deny on object_permission) and ablated (mirroring the guard turns that pin red). The spec's allow TSDoc needs no second clause.
  2. The registration log line is now read off the registered object (Object.keys(registeredSecurityService), sorted), so it cannot fall behind a served member again. The hand list named 16 of the 21 members served before this PR. It omitted describeDelegableScope, describeDelegationNarrowing, getEffectiveObjectPermissions, hasWriteBypass and resolveWriteScope. It now prints all 22.

Local verification (HEAD 15d05d2b6)

  • Census: node scripts/check-system-context-census.mjs at 15d05d2b6 reads check-system-context-census: OK — 122 elevation read sites in 20 packages across 57 files, living in 104 symbol(s); the page cites 117 symbol(s) against 117 required. The gate's own --fix is a no-op on the committed page (blob unchanged before and after).
  • Gates: dispatch-gates --commands derived 105 families at 15d05d2b6. That is the 79 from the previous head plus 26 that the docs path adds (doc frontmatter, route spelling, section names, landing index, doc anchors, docs redirects, single H1, spec check:docs, and others). All 105 were run with recorded exit codes, and every one exited 0. --ran reads: 105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED.
  • Workspace: rebuilt in the recreated worktree (turbo run build, 73/73 tasks) before the gates ran.
  • Tests: the code is unchanged since bdce0149d, so these readings carry over. At bdce0149d, after rebuilding:
    • the six cbp suites in plugin-security: 106 passed;
    • the attachment and comment gate suites: 75 and 67 passed;
    • the new dogfood file: 10 passed.
  • Full package suites at faea6141f (main has moved since, through merges that touch none of these packages):
    • plugin-security: 191 files, 3969 passed / 45 skipped;
    • service-storage: 47 files, 800 passed;
    • plugin-audit: 42 files, 672 passed;
    • typecheck for all three, test layers included: exit 0.
  • ESLint over the 14 changed .ts files: 14 results, 0 errors, 0 warnings. No type-aware linting is enabled, so untouched files' verdicts cannot move.
  • Main moved: after the census commit, main moved by one commit, 4e9fe9ff6 (the protocol version bump). It touches neither the census page, these packages nor any isSystem read in code, so it was not merged this round.

Acceptance notes

  • Measured, other lane (class a): POST /api/v1/security/explain for the member, { object: 'cpg_contract', operation: 'update', recordId }, answers allowed: true with the OWD layer reading "controlled_by_parent: rows are org-shared at this baseline". The same member's PATCH of that record answers 403. Measured at faea6141f with an untracked scratch probe on this PR's fixture, deleted after the reading. The explain record write gate asks sharing.canEdit (canEditRecord), whose abstention reads as permission. The member this PR serves is the parity path. Not fixed here.
  • Spec TSDoc drift: the ISharingService.canEdit TSDoc still names the sys_attachment parent gate (and, under the write-depth paragraph, both parent-record gates) as canEdit callers. Both now read checkEdit. No runtime consumer; carrier: the spec lane's TSDoc follow-up, card spec(contracts): the ControlledByParentWriteDenialLeg TSDoc says master_chain refusals name a master above the record's own master; the walk's first hop reads the record's own master #22497.
  • The same caller list sits in plugin-sharing's SharingService.canEdit docblock. Doc only; carrier: none.
  • The comment UPDATE limb moved with the delete limb (one function). For org members the platform's edit floor answers that limb first, so the visible change is on delete.
  • Two changesets, not one. The served member is a non-breaking minor for plugin-security, and the BREAKING banner rides only on the two packages that narrow. The ADR-0087 disposition is not-required (no-migration-prescription). The remedy ("grant edit on the master") is written as prose, because the gate refuses that category when a FROM-TO block is detected and no other category applies to a runtime accept-set narrowing.
  • scripts/engine-double-contract.pinned.json gains one row, written by --write for the new suite's pinned findOne double.

Generated by Claude Code

claude added 10 commits October 9, 2026 15:30
… a controlled_by_parent parent

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…Write from the write path's own composition

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…y-id update, and pin that it stays served

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…by_parent parent through its master

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…_parent parent through its master

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…eg for a floor-bound member

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…d master-detail write check

Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ
Co-authored-by: Claude <noreply@anthropic.com>
…suite's pinned findOne double

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

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-audit, @objectstack/plugin-security, @objectstack/service-storage, touching 27 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/index.ts, packages/services/service-storage/src/attachment-delete-floor-alternate.ts, packages/services/service-storage/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/kernel/index.mdx (via canEdit (symbol, a method of interface AttachmentSharingLike), canEdit (literal, a string literal in CommentSharingLike))
  • content/docs/kernel/runtime-services/examples.mdx (via canEdit (symbol, a method of interface AttachmentSharingLike), canEdit (literal, a string literal in CommentSharingLike))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via canEdit (symbol, a method of interface AttachmentSharingLike), canEdit (literal, a string literal in CommentSharingLike))
  • content/docs/permissions/attachments-access.mdx (via canEdit (symbol, a method of interface AttachmentSharingLike), canEdit (literal, a string literal in CommentSharingLike))
  • content/docs/permissions/authorization.mdx (via not_applicable (literal, a string literal in checkControlledByParentWrite; a string literal in installAttachmentAccessHooks; a string literal in installCommentAccessHooks))
  • content/docs/permissions/explain.mdx (via not_applicable (literal, a string literal in checkControlledByParentWrite; a string literal in installAttachmentAccessHooks; a string literal in installCommentAccessHooks))
  • 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/rls.mdx (via not_applicable (literal, a string literal in checkControlledByParentWrite; a string literal in installAttachmentAccessHooks; a string literal in installCommentAccessHooks))
  • content/docs/permissions/system-context.mdx (via checkControlledByParentWrite (symbol, a method of class SecurityPlugin), installAttachmentAccessHooks (symbol, a top-level function), installCommentAccessHooks (symbol, a top-level function), checkControlledByParentWrite (literal, a string literal in AttachmentSecurityLike; a string literal in CommentSecurityLike))
  • content/docs/plugins/packages.mdx (via SecurityPlugin (symbol, a top-level class))
  • content/docs/protocol/objectql/security.mdx (via canEdit (symbol, a method of interface AttachmentSharingLike), canEdit (literal, a string literal in CommentSharingLike))
  • content/docs/protocol/objectui/concept.mdx (via canEdit (symbol, a method of interface AttachmentSharingLike), canEdit (literal, a string literal in CommentSharingLike))
  • content/docs/ui/forms.mdx (via SecurityPlugin (symbol, a top-level class))

⛔ 2 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 canEdit (symbol, a method of interface AttachmentSharingLike), canEdit (literal, a string literal in CommentSharingLike))

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
  • 3 changed file(s) yielded no anchor (packages/plugins/plugin-audit/src/index.ts, packages/services/service-storage/src/attachment-delete-floor-alternate.ts, packages/services/service-storage/src/index.ts) — pages documenting those are invisible to this run
  • 1 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 — 26 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 446c8b2a6420a61a2862e6f5140dda71a53316d1 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 446c8b2a6420a61a2862e6f5140dda71a53316d1

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

…r-detail write check's system exit

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 15d05d2b697153d2e126285e037a9d49885fcd44
Local-runs: none

Inputs read: card #22455 (body and all 10 comments, the amended claim 6083853580 in its current body), PR #22513 (body, 18-file list, net diff against merge-base 446c8b2a6), the 35 latest check-runs on the head with their annotations, packages/spec/src/contracts/security-service.ts at the head, and #22464's landing record 6083231669. Nothing was built, run or re-run. Read at 2026-10-09T17:32Z by the seat's isolated review subagent.

Check-runs on the head: every latest run concludes success or skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke are path/opt-in skips). The only warning annotation is Test Core's repo-wide "suite-duration ceilings NOT MEASURED, no ceiling table yet", not this PR's. Check Changeset records the ADR-0087 exemption it read (not-required (no-migration-prescription) on 22455-cbp-parent-gates-judge-master.md). Governed Surface Queue Guard is green: no governed path in the file list.

① Derived judgments

Truth tables against ruling A (6079158667, carried by 6079448762), cell by cell. Both gates run one composition each (mayEditParent in attachment-access-hooks.ts, canEditParent in comment-access-hooks.ts), and the two are textually the same rule:

cell ruling diff judged
checkEdit allow admits if (verdict === 'allow') return true right
checkEdit deny refuses, today's envelope if (verdict !== 'abstain') return false, then each limb's own forbid right (also refuses a verdict outside the three-value vocabulary, fail-closed)
abstain on a controlled_by_parent parent asks the member every abstention asks checkControlledByParentWrite; the member is the one holder of the controlled_by_parent predicate (declaresControlledByParent) right, no second copy of the predicate in either gate
any other abstain admits the member answers not_applicable for a non-controlled_by_parent object and the gate admits on it right. The dev's reading matches the ruling's text ("any other abstain admits") AND the spec's own consumer rule (ControlledByParentWriteOutcome TSDoc: "proceeds on allow and on not_applicable, and refuses on every other outcome"). The claim's first spelling (fail-closed on not_applicable) would have refused the public_read_write control the ruling pins; the amendment is the correct one
non-verdict outcomes fail closed unresolvable (all three reasons) returns false right
store fault keeps 503 the member rethrows anything controlledByParentWriteOutcomeOf cannot classify; the gates await without a catch right, pinned in both gates and in the member suite
kernel without the member keeps today's admit typeof security.checkControlledByParentWrite !== 'function' returns true with a debug line that does not claim the master was judged right, matches the spec's "absence is the absence of a master check" and its ⛔ on reporting it as allow
no sharing service (not in the ruling) caller-scoped parent READ, as before unchanged

All four attachment limbs (attach, update row rule, delete row rule, re-point) and both comment moderation limbs (delete, update) go through that one function; the comment insert limb still asks READ only. Right.

One composition on the plugin-security side. resolveOperationPrincipals is the middleware's prologue moved verbatim: isPrincipalLessContext → principalLessDenial (same function), permission resolution failing closed (same PermissionDeniedError text; the sink is this.logger, which init binds to ctx.logger before any middleware runs, and error?. is required by SecurityReportSink's optional error), then the delegator resolution (same missing-delegator text and meta). In the old middleware the only statement between resolution and delegator resolution was let preImageFloorOpts = null, so the order of refusals is unchanged. The member runs assertControlledByParentWrite(permissionSets, object, 'update', opCtx, context) then the delegator pass, step 2.8's two calls in step 2.8's order; its opCtx carries options.where.id (what extractSingleId reads) and context (what assertMasterRowEditable reads the delegation link off, on both passes). Legs are tagged in a WeakMap beside the error, so the write path's thrown envelope is byte-identical; the three non-verdict errors map one-to-one onto the unresolvable reasons; master_chain tags every above-first-hop refusal, as the spec's leg TSDoc says. The member cannot drift from the write path because it IS the write path's function. Judged right.

Serving note 1 (step 2.8's context.userId guard not mirrored). Right, and the brief's two questions answer yes:

  • It can only refuse. Mirroring would return allow without running a leg; not mirroring runs the legs, whose outcomes are a subset of allow. For the one principal shape the guard covers (positions or sets, no user id, not system) the member can answer deny where the parent's own update skips step 2.8; that is a refusal, never an admission, and the dev's pin plus ablation B hold it.
  • It matches the spec's allow TSDoc as written: allow is "every leg passes on every master up the chain, or a system context". A mirrored guard would have produced an allow the TSDoc does not cover, which is exactly why spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464's record said mirroring would need a second clause. No clause is needed.
  • Residual named, not a defect: the plugin-security changeset's sentence "its own update is refused by the object-level gate anyway" is true of today's guest bindings (they refuse allowEdit); for a hypothetical sets-bearing, user-less principal that does hold allowEdit the parent's update would be admitted (step 2.8 skipped) while a gate composing the member may refuse on the master's floor. Refuse-only, dormant, and the parent's own PATCH of the MASTER would be refused by the same floor, so that principal is not "the master's editor".

Serving note 2 (registration log line read off the object). Object.keys(registeredSecurityService).sort() enumerates the typed literal and the two Object.assigned extension members alike, so the line cannot fall behind a served member again. A log string, not a contract. Right.

Published-surface changes, by exports map (. entry of each package; types reachable from index.ts):

  • @objectstack/service-storage: AttachmentSharingLike changes from an interface with canEdit to a Pick of ISharingService on checkEdit — a REPLACEMENT of the accepted getSharing shape (a canEdit-only object no longer type-checks; checkEdit is required). Breaking, declared. installAttachmentAccessHooks gains an optional trailing getSecurity — additive. New exported type AttachmentSecurityLike — additive.
  • @objectstack/plugin-audit: CommentSharingLike moves its Pick of ISharingService from canEdit to checkEdit — same replacement, breaking, declared. installCommentAccessHooks optional trailing getSecurity — additive. New exported type CommentSecurityLike — additive.
  • @objectstack/plugin-security: the registered security service now serves the optional checkControlledByParentWrite — additive; resolveOperationPrincipals and checkControlledByParentWrite are private on SecurityPlugin, so the class's public type is unchanged.
  • @objectstack/spec: untouched. @objectstack/dogfood (private): tests only.
  • Runtime accept set: sys_attachment insert/update/delete/re-point and sys_comment update/delete on a controlled_by_parent parent whose master the caller may not edit are refused where they were admitted. Narrowing, declared BREAKING with the remedy.
  • One accept-set note on the feature-detection predicate: an off-type getSharing object serving only canEdit (JS or a cast) now takes the degraded READ-visibility path instead of the edit check. The port type is the contract, the type change is declared breaking, the in-repo wiring passes the real ISharingService, and the fixed group versions in lockstep, so no supported consumer reaches it. Named, not failed.
  • No pinned-sibling exposure: ../objectui at the pinned sha imports none of the four symbols.

Security reading.

  • PATCH parity on the master-detail axis: for a controlled_by_parent parent the gate's answer on abstention is step 2.8's own composition for the same (object, recordId, context), pinned at plugin level (allow iff admitted; each deny leg matches the refusal's reason; delegator leg; master_chain; three unresolvable reasons each meet a refused update) and on the real stack (deny/row_level_security beside a 403 whose text names the row-level-security leg; owner allow beside a 200). No path admits a write the record's own PATCH refuses on that axis. Residual, pre-existing and model-independent: the gates never asked the parent's own object-level update grant for any sharing model (the authority on an attach is the sys_attachment bit plus parent EDIT at record level), and this PR does not change that; the ruling scoped parity to the master check.
  • Nothing newly refused for the master's editor: owned master, edit-level share, authored write-RLS admission, modifyAllRecords and a two-hop chain all answer allow and admit (pinned); the uploader and the author keep their own-row shortcut before the parent is asked; public_read_write admits via not_applicable.
  • No leak: the attach limb answers ATTACHMENT_PARENT_ACCESS with the unchanged "does not exist or you cannot edit it" sentence for cannot-read, cannot-edit and non-existent parents alike (a record_not_found unresolvable is indistinguishable from a deny); the row rule still routes a caller who cannot read the parent to refuseNotVisible → PERMISSION_DENIED through the read evaluator (pinned in both gates); the member's leg/reason never reach a response (the gate reduces the outcome to a boolean, and the WeakMap puts nothing on the error). The system-context readRowById reads inside the member are the write path's own and return nothing to the caller.
  • "A parent that is not controlled_by_parent is judged exactly as before" (changeset): true for every context the write path admits. A context the write path refuses before any gate (principal-less, unresolvable sets, dangling delegator) now meets that same 403 at the gate on an abstention instead of the old admit, because the member runs the prologue before not_applicable. Refuse-only, the spec's declared behaviour, and unreachable through the REST doors (which refuse those contexts first). Named.

File list against the amended claim's surface. Outside the named files, all declared by the dev: packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts and test/fixtures/cbp-parent-gates-fixture.ts (the real-stack pins; the claim's "dev's measured controls" were a dogfood rig, and the round-4 claim named one dogfood pin), scripts/engine-double-contract.pinned.json (one --write row the gate demands for the new suite's findOne double), packages/services/service-storage/src/attachment-delete-floor-alternate.ts (a doc comment canEdit → checkEdit), and the wiring edits inside the three claimed packages (audit-plugin.ts, storage-service-plugin.ts, both index.ts). None widens; the file list carries no packages/spec, no plugin-sharing, no governed path.

② Semver level

  • Clause-②: no (narrowing) is right. The declaration limb (check-changeset-no-major: "a new key on a published payload") is not met: no payload key, no spec change; the member's declaration and widening were spec(contracts): ISecurityService declares an optional member answering the master-detail write check — the packages/spec half of #22455, split out under 强制条款② #22464's. The arm is (narrowing), which AGENTS.md makes BREAKING, and the breaking changeset carries the BREAKING banner, the Clause-② line and exactly one ADR-0087 marker, as check:adr-0087-registration requires. Note for the seat: the diff also adds two exported types and an optional parameter to two published indexes; that additive act takes at least minor under the WHICH LEVEL ruling, and both packages are graded minor, so the level is right either way, and this record is the contract-tier review a widening declaration would have owed.
  • Levels on the v18 prerelease line (.changeset/pre.json mode pre, tag next; major refused during the launch window): @objectstack/service-storage and @objectstack/plugin-audit minor + BREAKING — right; @objectstack/plugin-security minor, non-breaking — right (serving a declared optional member is an additive public-surface widening; no by-id write is judged differently; the log line is not a contract).
  • Breaking changeset, sentence by sentence against the code: the cause paragraph (effectiveSharingModel → public → checkEdit abstains → canEdit folded it into true) is the measured mechanism; the composition paragraph matches mayEditParent/canEditParent; every envelope named is the code's constant (ATTACHMENT_PARENT_ACCESS on attach and re-point, ATTACHMENT_DELETE_DENIED on delete, RECORD_NOT_ACCESSIBLE on attachment update and on comment delete/update, PERMISSION_DENIED not-visible); "What is unchanged" holds (uploader/author shortcut precedes the parent question, comment insert asks READ, absent member admits); the remedy "grant edit access on the MASTER record: ownership, an edit-level share, modifyAllRecords, or an app-authored update policy admitting the row" names the mechanisms that satisfy the row-level-security and record-sharing legs. Precision note, not a defect: the object_permission leg (an update grant on the master OBJECT) is required alongside any of them; "edit access on the master record" implies it, and a principal lacking it cannot PATCH the master either. The port-type and installer sentences are true of the diff.
  • ADR-0087 disposition not-required (no-migration-prescription): honest for the accept-set narrowing (no spec key, authorable spelling or stored row moves; the remedy is an access decision, not a rewrite), and the gate's one mechanical check passed. The more precise disposition for the two replaced port types would have been runtime-interface-only naming attachment-access-hooks.ts#AttachmentSharingLike and comment-access-hooks.ts#CommentSharingLike (a published runtime interface the compiler reaches, no metadata surface); that category is a narrowing of the catch-all, not a replacement for it, so the catch-all stays legal here. Optional tightening, not a FAIL.
  • plugin-security changeset: each sentence checks against the diff; the "where it differs from the write path's guard" paragraph is accurate with the residual named in ①.

③ Boundary flags

Dev flags (report 6085315470 deviations, PR acceptance notes) and the one open_questions entry:

  1. open_questions — may this PR carry the one census row? Answered by the seat (claim amended; row 9b landed in 15d05d2b6). Judged right: option C reds main in either landing order, option B contradicts the contract's "a system context answers allow". Row 9b's text is true of the served member's system exit: if (context?.isSystem) return { outcome: 'allow' } is the first statement, it is the middleware's own system short-circuit, and both consumers return on ctx.session.isSystem before any parent question, so "Lose: nothing a caller reaches today" holds. The counts (121→122 sites, 127→128 reads, 118→119 behaviour-bearing, 103→104 symbols) are one new site in one new symbol, and the census gate is green on the head. Collision to carry: PR fix(runtime)!: the /i18n dispatcher domain refuses an anonymous caller, with the console pin moved past the sign-in companion (#22432) #22496 (open, draft) also moves this page's counts (+10/-10); whichever of the two lands second re-runs check:system-context-census --fix on the merged page before queueing.
  2. not_applicable admits (claim amended in place): judged right in ①.
  3. Two changesets instead of one 22455-*.md: right; the BREAKING banner belongs only to the packages that narrow, and both files are 22455-prefixed.
  4. Remedy as prose, no FROM → TO block: acceptable, see ②.
  5. Step 2.8 guard not mirrored: right, see ①; no spec TSDoc change is owed.
  6. Middleware prologue refactor: verified line by line in ①; refusals, order and wording unchanged.
  7. Files outside the claim surface: named in ①; all declared, none widens.
  8. Exported port types changed; installers take an optional trailing resolver: declared in the BREAKING changeset; judged in ① and ②.
  9. main merged twice during the run, not rebased: the head's merge-base is 446c8b2a6; origin/main is one commit ahead (4e9fe9ff6, protocol version bump, touching none of these paths). CI ran on the head as pushed. No action.
  10. Out-of-scope, class (a), measured — security/explain answers allowed: true for an update the same member's PATCH refuses (canEditRecord asks sharing.canEdit; describeOwd reads controlled_by_parent as org-shared). Triage's 6079448762 deferred filing until the member existed and it could be measured; it now has been measured and reproduced on this PR's fixture. Escalated to the seat: Prime Directive chore: version packages #10 owes a card for a reproducible defect; file it with the dedupe words the dev listed (explain controlled_by_parent update allowed, explain canEditRecord master-detail, security explain parity checkControlledByParentWrite) and name the served member as the parity path. Not a defect of this diff.
  11. TSDoc drift: ISharingService.canEdit (spec) still lists the sys_attachment parent gate among canEdit callers — carrier spec(contracts): the ControlledByParentWriteDenialLeg TSDoc says master_chain refusals name a master above the record's own master; the walk's first hop reads the record's own master #22497 (spec lane), right. SharingService.canEdit's docblock in plugin-sharing carries the same list with "carrier: none" — escalated to the seat: a one-line docs rider on spec(contracts): the ControlledByParentWriteDenialLeg TSDoc says master_chain refusals name a master above the record's own master; the walk's first hop reads the record's own master #22497's PR or a docs-only PR; plugin-sharing was outside this claim's file surface, so leaving it was correct.
  12. Comment UPDATE limb moved with delete (same function): right; for org members the platform edit floor answers first, so the visible change is on delete; pinned anyway.

Implemented-by: claude/issue-22455-attachment-gate-master-write
Reviewed-by: session_01WYYhVJ78u7PhwFViWo1EmQ

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 17:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 17:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit ce3d0ad Oct 9, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22455-attachment-gate-master-write branch October 9, 2026 18:08
os-elon-musk pushed a commit that referenced this pull request Oct 9, 2026
system-context.mdx: both sides' prose kept (main's row 9b from #22513,
this branch's row-51 anchor packages/runtime/src/domains/i18n.ts#handleI18nRequest);
counts re-derived by pnpm gen:system-context-census.
registry.ts: re-derived by pnpm --filter @objectstack/spec gen:migration-registry
(byte-identical to the text merge).

Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS
Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Oct 9, 2026
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