Skip to content

feat(service-storage, plugin-audit): the parent gates ask the parent's by-id update alone; explain composes the on-behalf-of write - #24745

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24646-parent-gates-one-question
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24646-parent-gates-one-question

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24646

Clause-②: yes

The sys_attachment parent gate and the sys_comment moderation gate now ask one question about the parent record: the security service's record-grained judgement of a by-id update of it. Ruling item 3 made a measurement the precondition. It found that security.explain's verdict for an on-behalf-of (OAuth agent) caller did not match that caller's by-id PATCH, so this PR first makes explain compose the delegated write path, as item 3's fork arm requires, and only then drops the two steps.

The ruling (comment 6279880675 on this card), items 1–3

  1. The sys_attachment parent gate (packages/services/service-storage/src/attachment-access-hooks.ts) and the sys_comment moderation gate (packages/plugins/plugin-audit/src/comment-access-hooks.ts) drop their own-depth record-sharing step and their master-detail step; 「may edit the parent」 is the security service's record-grained by-id update judgement of the parent alone (explain, admitting only allowed === true && record.visible === true), the question PR fix(service-storage,plugin-audit): the attachment and comment parent gates give the answer a by-id update of the parent gets #24641 already asks third.
  2. The governing paragraph 「Write DEPTH is an input the CALLER supplies — absent means own」 in packages/spec/src/contracts/sharing-service.ts is rewritten: the parent-record gates no longer ask this service; the 2026-08-10 ruling on Decision needed: should the sys_comment parent gates run the parent's owner-match at the caller's real write DEPTH, or stay at own? #7144 (comment 5236141537, Option 1: the gates STAY at own) is superseded for the parent gates by this ruling, and Decision needed: should the sys_comment parent gates run the parent's owner-match at the caller's real write DEPTH, or stay at own? #7144 carries a pointer.
  3. Premise first (三件套 ②): before the two steps are removed, the dev measures on a real stack, in both postures, that for an on-behalf-of caller security.explain({ operation: 'update', recordId }) on the parent answers the same as the CRUD by-id update of that parent. ③ If it does not, the dev reports the fork and fixes explain's delegated path first, on this card — ⛔ never drops the two steps anyway, ⛔ never falls back to A in silence.

Item 3: the on-behalf-of measurement, and the fork it found

Rig. The real SecurityPlugin, a real ObjectQL over better-sqlite3, the real SharingService and its middleware, and real platform identity objects. Each caller's context is assembled by the real resolveAuthzContext and assembleExecutionContext. The on-behalf-of caller is an OAuth agent with the mcp_agent_data_write ceiling acting for the user. There are three users: an org-write-depth delegator, an own-write-depth delegator, and a third user who owns the rest. There are three objects: a private parent with owner and creator columns, a private master, and a controlled_by_parent detail. The rig crosses 13 (user, record) pairs with two principals (the user itself, or the agent on their behalf) and two postures (isolated, which is walled, and single, which is wall-less), for 52 cells. Each cell compares security.explain({ object, operation: 'update', recordId }) against the by-id PATCH of the same record.

tree cells parity fork
base ec2694dc19, before the explain change 52 48 4
this branch, after the explain change 52 52 0

All four forks have the same caller, an agent acting for the org-write-depth delegator, with two per posture. The fork ran in both directions:

  • explain refused a row the PATCH admits. The row is owned by someone else and was created by the delegator. Explain asked record sharing's per-record gate once, with no depth stamped, at own. The write asks it twice: once as the agent at its agent-leg depth, then as the delegator at the delegator's own depth.
  • explain admitted a row the PATCH refuses. The row is owned by the delegator and was created by someone else. Explain left out the delegator's own row-level write filter. The write's pre-image check ANDs that filter beside the agent's.

The human (non-delegated) cells were at parity before and after, in both postures. That includes the org-depth writer on someone else's row: the PATCH and explain both admit it, and the pre-B gates refused it. That refusal is the narrower duplicate this ruling removes.

Under ③, explain's delegated path is changed first, on this card, in commit 7da68682b1. Item 3 was then measured again (the table's second row), and only after that were the two steps dropped (ba6fb4912c).

What changes

  1. @objectstack/plugin-security: explain composes the on-behalf-of by-id write (security-plugin.ts explain binding, explain-engine.ts record verdict).
    • For an on-behalf-of caller, record sharing's per-record update / delete gate is asked the way the sharing middleware asks it. The first call is as the agent, with step 2.6's agent-leg __writeScope (intersectDelegatedScope) and __delegatorWriteScope. If that admits, the second call is as the delegator at the delegator's depth. A delegation link that names no user refuses.
    • The authored-row-write probe receives the same stamped agent context.
    • The record verdict of an update / delete ANDs the delegator's own write filter (computeRlsFilter over the delegator's sets and context, the same call step 2.7 makes) beside the principal's Layer 1. A rejection is reported fail-closed, on rls.
    • Ordinary callers and the delegated READ verdict are unchanged.
  2. The two gates ask one question (attachment-access-hooks.ts, comment-access-hooks.ts). Wherever the security service serves explain, "may edit the parent" is its record-grained update verdict for the parent alone (allowed === true && record.visible === true). Record sharing's checkEdit and the master-detail check are not asked there. A store fault propagates unchanged. Uploaders keep their own attachments and authors their own comments. The not-visible refusal for a caller who cannot read the parent is unchanged, and so is the read rule for posting a comment.
  3. Degraded kernels keep today's composition exactly (the seat's safety constraint).
    • A security service that serves no explain, or no security service at all, judges no by-id update. There the gate keeps record sharing at own, then the master-detail check on an abstention. With no sharing service, the caller-scoped parent read stands alone.
    • With no sharing service but explain served, the parent read is still asked first and the judgement second, as today.
    • Dropping the steps on a kernel that serves no explain would admit every caller. Both module headers say so, and a degraded-kernel suite in each gate's tests holds it.
  4. packages/spec/src/contracts/sharing-service.ts, prose only.
  5. content/docs/permissions/attachments-access.mdx. The "Create — parent-edit access" section described the three-question composition, and it would have read false after this change. Not in the claim's file surface, but this change is what made it false.
  6. Changesets.
    • .changeset/24646-parent-gates-one-question.md: minor for @objectstack/service-storage, @objectstack/plugin-audit and @objectstack/spec, Clause-②: yes (widening), with ruling item 5's sentence.
    • .changeset/24646-explain-on-behalf-of-write-verdict.md: patch for @objectstack/plugin-security.

The behaviour, in item 5's words: a caller who can update the parent record by id can also attach files to it and moderate its comments; a caller who cannot, still cannot. The gates only stop refusing what the CRUD path already admits.

Pins

  • Item 3, kept as rows (explain-enforce-parity.test.ts, rig 4, #24646 item 3). There are 15 rows per posture, under isolated and single, covering update and delete. Each row checks explain against the by-id PATCH / DELETE for an on-behalf-of caller. The delegators' own requests are the controls.

  • The pin security(explain): explain's record-grained update verdict refuses a row an app-authored update policy admits, while the PATCH of that row is admitted (ADR-0105 D3 deferral missing on explain's record path) #24647 pre-registered, flipped and kept load-bearing. The case is "the parent gates' one question, the by-id update verdict, admits the row the by-id update admits". It asserts four things:

    • record sharing alone still answers deny;
    • explain's record verdict is visible: true;
    • the PATCH is admitted;
    • the real sys_comment moderation gate (plugin-audit source, wired to the rig's own security and sharing services) admits the moderation.

    On the row the policy does not admit, the PATCH, explain and the gate all refuse, the gate with RECORD_NOT_ACCESSIBLE / 403.

  • The real gate on both postures (7 cells × 2). The moderation gate admits exactly where the parent's PATCH does:

    • the org writer on someone else's row (the widening);
    • a controlled_by_parent detail judged through its master, admitted under an editable master and refused RECORD_NOT_ACCESSIBLE / 403 under a master the caller reads but may not edit;
    • an unreadable parent refused PERMISSION_DENIED / 403;
    • both delegated fork shapes.
  • Gate units (attachment-access-hooks.test.ts, comment-parent-update-parity.test.ts). Each gate has 10 shapes on every limb. Where explain is served, the gate answers exactly what the judgement answers, and checkEdit and the master check are never called. Two rows show the change itself:

    • record sharing at owner-only depth refuses and the judgement admits: the gate admits;
    • the master check alone would admit and the judgement refuses: the gate refuses.

    Refusals assert the ADR-0112 envelope (code and status). A degraded-kernel suite holds today's answers where explain is not served.

Ablations

Every ablation was run from committed HEAD, through scripts/ablation-replace.mjs in wrap mode with a trap restore. In every case the anchor moved 1 → 0, the restored blob equals the HEAD blob, and git diff HEAD is empty. The subjects resolve to source (the vitest alias for @objectstack/plugin-audit), so there is no dist leg.

# forbidden behaviour put back suite result
A1 explain's record verdict without the delegator's write filter parity 8 red / 150: the delegated "own row someone else created" update and delete, "row owned and created by someone else", the real-gate agent cell, both postures
A2 explain's per-record gate asked once, unstamped, for a delegated caller parity 6 red / 150: the delegated "row owned by someone else that it created" update and delete, the real-gate agent cell, both postures
G1 attachment: the two steps back in front of the judgement gate 18 red / 141
G2 attachment: the judgement's refusal ignored gate 32 red / 141
G3 attachment: a kernel serving no explain admits gate 52 red / 141
G4 attachment: the old steps decide instead of the judgement gate 39 red / 141
C1 comment: the two steps back gate 12 red / 36
C2 comment: the judgement's refusal ignored gate 14 red / 36
C3 comment: a kernel serving no explain admits gate 4 red / 36
C4 comment: the old steps decide gate 21 red / 36
C5 comment: the two steps back, on the real stack parity 5 red / 150: the flipped pin and the real-gate org-writer and agent cells, both postures

Tests

Package suites ran at 79c3d98b98, the merge of origin/main 9b2953d62c. The only later commit, d35ec05d55, is a type annotation in one test file. Both commands for that file were re-run at d35ec05d55.

  • pnpm --filter @objectstack/service-storage test: 62 files, 1129 passed. typecheck: exit 0.
  • pnpm --filter @objectstack/plugin-audit test: 58 files, 950 passed. typecheck: exit 0.
  • pnpm --filter @objectstack/plugin-security test: 189 files, 3839 passed, 45 skipped.
    • typecheck: exit 0 at d35ec05d55. It had found 2 errors at 79c3d98b98, in the new cells' expected-answer type, and d35ec05d55 fixes them.
    • explain-enforce-parity.test.ts: 150 passed at d35ec05d55.
  • pnpm --filter @objectstack/spec test: 702 files, 21352 passed, 1 todo. typecheck: exit 0.
  • pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated: all 14 generated artifacts up to date.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 114 families from this diff (merge-base origin/main 9b2953d62c). I ran all 114 at d35ec05d55, each exit code captured before any pipe, and all 114 exited 0.

The first run, at 79c3d98b98, had three families refuse with PREREQUISITE NOT MET (exit 3), and none of the three measured anything: check:skill-examples (client SDK dists), check:dual-build-cjs-loads (package dists) and check:i18n (the CLI build closure). Their prerequisites were built (turbo run build of that closure), and then the whole set was run again on the final head.

--ran reconciliation: "114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0). The local-only pnpm check:dev-prereqs also exits 0, since this diff touches packages/spec. CI runs the full battery.

Acceptance notes


Generated by Claude Code

…oses the delegated write path

The record-grained update/delete verdict for an on-behalf-of principal now
asks plugin-sharing's per-record gate as the by-id write does (agent leg at
its intersected depth, then the delegator at the delegator's depth), and
ANDs the delegator's own write filter beside the principal's, as step 2.7
does. Measured on a real stack before the fix: 4 of 52 cells forked (both
postures), in both directions; after: 0 of 52.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…s by-id update judgement alone

The sys_attachment parent gate and the sys_comment moderation gate drop
their own-depth record-sharing step and their master-detail step wherever
the security service serves explain: may-edit-the-parent is the record-
grained by-id update verdict of the parent alone. A kernel whose security
service serves no explain keeps the previous composition exactly. The
ISharingService contract prose records the superseded own-depth rule.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
… parent-gate pin, and the real comment gate on both postures

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…lain's on-behalf-of write verdict

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…dmit-or-envelope

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
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 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/plugin-audit, @objectstack/plugin-security, @objectstack/service-storage, @objectstack/spec, touching 15 documentable anchor(s).

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

  • content/docs/kernel/contracts/index.mdx (via ISharingService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via ISharingService (symbol, a top-level interface))
  • content/docs/permissions/attachments-access.mdx (via ISharingService (symbol, a top-level interface))
  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a method of class SecurityPlugin), installAttachmentAccessHooks (symbol, a top-level function), installCommentAccessHooks (symbol, a top-level function))
What this run could not see
  • 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 — 150 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 39e93b9ef936c9cad11642520d45c2ca2755bdf4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 39e93b9ef936c9cad11642520d45c2ca2755bdf4

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d35ec05d557a6d9f0ac74ed9a1f691f7dca85865
Local-runs: none
Scope: packages/spec/src/contracts/sharing-service.ts + packages/services/service-storage/src/attachment-access-hooks.ts, packages/plugins/plugin-audit/src/comment-access-hooks.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/security-plugin.ts, content/docs/permissions/attachments-access.mdx, .changeset/24646-parent-gates-one-question.md, .changeset/24646-explain-on-behalf-of-write-verdict.md (read against the unchanged enforcement path at this head: packages/plugins/plugin-sharing/src/sharing-plugin.ts write middleware, security-plugin.ts steps 2.6 and 2.7, resolveOperationPrincipals, and packages/spec/src/contracts/security-service.ts). Remainder, the lane seat's ACCEPT at TIER_DEFAULT as a standalone PR of those files: attachment-access-hooks.test.ts, comment-parent-update-parity.test.ts, explain-enforce-parity.test.ts.

① Derived judgments

Net diff read at merge-base 9b2953d62c (origin/main) against the head; 11 files, +987/−349, the PR's file list and the net diff agree. Each accept-set and public-surface change the diff implies, judged:

  1. sys_attachment parent gate, mayEditAttachmentParent (attachment-access-hooks.ts). Where the security service serves explain, the record-sharing checkEdit step (asked with no depth, so at own) and the ADR-0055 master-detail step are no longer asked; the parent's record-grained by-id update verdict decides alone, admitting only allowed === true and record.visible === true, and a decision carrying no record verdict refuses. With no sharing service the caller-scoped parent read is still asked first. Widening: a caller whose write depth on the parent object is unit, unit_and_below or org, or one an app-authored row-level update policy admits, now attaches wherever the parent's by-id update admits. RIGHT — ruling item 1 to the letter. All four limbs (attach, parent-editor update and delete, re-point) and FileAttachmentService.assertMayAttach (file-attachment-service.ts L246) reach the one exported function (L532), so no limb drifts; a rejection propagates unchanged; refusal codes ATTACHMENT_PARENT_ACCESS / ATTACHMENT_DELETE_DENIED 403 unchanged; uploaders keep their own rows.
  2. sys_comment moderation gate, canEditParent (comment-access-hooks.ts). Same composition, same widening, RECORD_NOT_ACCESSIBLE 403 kept, the not-visible refusal and the posting read rule unchanged, authors keep their own comments. RIGHT — the two gates answer one question the same way, as the card requires.
  3. Degraded kernels (a security service serving no explain, or none). Both gates keep the composition they had: tri-state checkEdit at own (allow admits, deny refuses, abstain asks checkControlledByParentWrite, whose allow / not_applicable admit, absent member admits the abstention), no sharing service → degraded read alone. RIGHT — a restriction the ruling does not forbid; dropping the steps where no judgement exists would admit every caller, the fail-open the card refuses. No public shape moves: AttachmentSecurityLike, CommentSecurityLike, AttachmentSharingLike, CommentSharingLike keep their Pick shapes (doc text only); mayEditAttachmentParent's exported signature is unchanged; parentUpdateAdmits is module-private in both gates.
  4. security.explain's on-behalf-of record verdict for update / delete (security-plugin.ts explain binding; explain-engine.ts applyRecordAttribution). canEditRecord / canDeleteRecord go through delegatedWriteGate: as the agent with __writeScope = intersectDelegatedScope(declared, delegatorScope).agentLegScope and __delegatorWriteScope = the delegator's effective write depth, then, only if that admits, as the delegator (userId the delegator's, onBehalfOf cleared, __writeScope = delegator depth); probeAuthoredRowWrite is handed the agent leg; a delegation link naming no user refuses. The record verdict ANDs computeRlsFilter(delegatorSets, object, rlsOp, delegatorContext) (no floor option) beside the principal's Layer 1 as recordLayer1, a fault reported fail-closed on rls. Compared with the enforcement path at this head: the sharing write middleware (sharing-plugin.ts L1328–1343) builds its second gate context as {...exec, userId: onBehalfOf.userId, onBehalfOf: undefined, __writeScope: exec.__delegatorWriteScope} and hands the authored-row probe exec itself; step 2.6 (security-plugin.ts ~L2906–2932) stamps the two depths with the same two evaluator calls; step 2.7 (~L3064–3066) composes the delegator filter with the same call and no options and ANDs it into the same pre-image re-read; resolveOperationPrincipals resolves both principals' sets through the same resolveDelegatorContext and resolvePermissionSetsForContext, refusing a missing delegator. RIGHT — same construction, same order, both directions of the measured fork closed (a row explain refused that the write admits; a row explain admitted that the write refuses). The ordinary caller keeps the pre-existing withWriteScope; the delegated READ verdict is untouched and the binding says so. ExplainDecision shape unchanged; the rls layer's detail gains a sentence (reporting text, no contract field).
  5. ISharingService prose, the hit (sharing-service.ts L220–258 and the canEdit block L306–311). The "Write DEPTH is an input the CALLER supplies — absent means own (Decision needed: should the sys_comment parent gates run the parent's owner-match at the caller's real write DEPTH, or stay at own? #7144)" paragraph now records that the parent-record gates no longer ask this service, that the 2026-08-10 ruling on Decision needed: should the sys_comment parent gates run the parent's owner-match at the caller's real write DEPTH, or stay at own? #7144 is superseded for them by the ruling on Decision needed: should the sys_attachment / sys_comment parent gates drop their own-depth sharing step now that they ask the parent's by-id update judgement (#7144 revisited)? #24646 (letter B), what they ask instead and under what admission rule, keeps the resolveWriteScope fail-open as the reason the gates never resolve a depth themselves, states why the by-id judgement does not meet it (object-level grant first), and names the one residual path (a kernel serving no explain, checkEdit at own, the narrow direction). The canEdit sentence that pointed at the old shape is rewritten to match. No type, member or export moves; the heading the two module headers cite by name is kept, so their pointers still resolve. RIGHT — ruling item 2 verbatim; both gate module headers and their callerContext notes state the same rule; no contradictory prose remains in packages/spec (security-service.ts's checkControlledByParentWrite doc, "such as a gate on a record's attachments or comments", stays true on the no-explain path). Spec property liveness success; the prose feeds no generated artifact.
  6. content/docs/permissions/attachments-access.mdx. The "Create — parent-edit access" section now states the one question, what the verdict composes, the widening in words, and the degraded-kernel paragraph. RIGHT — matches the code read above. Residual nit, no defect: content/docs/permissions/system-context.mdx row 9b still names the two gates as the master check's consumers, which stays true where no explain is served.
  7. Public surface overall. No export added, removed or re-typed in service-storage, plugin-audit, plugin-security or spec; check:generated is reported unaffected and Type Check · source gates is success on the head.

Gate verdicts on d35ec05d55, latest run per check name, as read 2026-10-20T04:14Z: success — Auto Label, Build Core, Build Docs, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, Lint · CI release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped (query-options erasure; slot lookup & corpus; tooling self-tests), No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, filter (27). Skipped by their own predicates — Console Pin Gate, Dogfood Regression Gate shards, Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (5). Still in progress at that read — Test Core 1/6 through 6/6 and Type Check · workspace (7); the queue's green-check condition reads them when they land, this record does not pre-empt them. No failure on the head.

② Semver level

  • .changeset/24646-parent-gates-one-question.md: @objectstack/service-storage minor, @objectstack/plugin-audit minor, @objectstack/spec minor; Clause-②: yes (widening); ruling item 5's sentence verbatim; names what widens, what does not move, the degraded-kernel retention and the spec prose. RIGHT — a widened runtime accept set in two released packages is a feature, and yes takes at least minor (AGENTS.md post-task step 3); the spec's published contract prose moves with the same ruling, whose item 4 fixes the level at minor. (widening) is one arm of the closed pair, well-formed.
  • .changeset/24646-explain-on-behalf-of-write-verdict.md: @objectstack/plugin-security patch. RIGHT — a fix in a released package (explain's delegated write verdict diverged from the write it explains) takes a patch, never none; no author-writable key, export or config moves, so nothing breaking and no ADR-0087 marker owed.
  • Clause-②: the PR body carries Clause-②: yes at the start of a line; the primary changeset carries Clause-②: yes (widening); the claim (6282082958), the card's protocol-change section and ruling item 4 say the same. Consistent. The review's face is a governed path (packages/spec/src/contracts/**), so the served tier is CONTRACT_REVIEW_TIER, not the declaration face. Check Changeset and Governed Surface Queue Guard are success on the head.
  • skip-changeset: not used, not applicable.

③ Boundary flags

Dev report 6283091147 — deviations, each answered:

  1. attachments-access.mdx edited outside the claim's file surface — right: content/docs/permissions/ is not release-owned, the section would have read false after the change, and Flag docs affected by code changes is success.
  2. A second changeset beside the named one — right (② above); the fork arm changed published plugin-security behaviour and a fix never ships without a changeset.
  3. Explain's delete verdict composed with update — accepted as one change, not escalated: in the write middleware the per-record gate (canDelete, ADR-0111 D3) and the step-2.7 filter are the same path for both verbs, so composing update alone would have left explain's delete verdict forked from the DELETE it explains; the change stays inside the fork arm's declared surface (the explain binding and engine), is named in the patch changeset, and is pinned by rig 4's delete rows. The parent gates ask only update. It exceeds ruling item 3's literal word update, in the direction the ruling's stated aim (explain equals the write) requires.
  4. One merge of origin/main (79c3d98) — procedural; the net diff against merge-base 9b2953d62c is the 11 files; security(explain): explain's record-grained update verdict refuses a row an app-authored update policy admits, while the PATCH of that row is admitted (ADR-0105 D3 deferral missing on explain's record path) #24647's landing 71a8b5eed6 is an ancestor of that merge-base, so the Blocked-by: was cleared on the tree reviewed.
  5. Commit trailer form — right: all five commits carry AGENTS.md's model-free pair (Claude-Session: URL and Co-authored-by: Claude), the repo rule the pre-push hook enforces; the harness reminder's model-bearing form is the one it refuses.

open_questions[1] degraded kernels — answered, option A (implemented) is right, not escalated: the ruling binds the gates where a by-id update is judged; where no judgement is served there is nothing to defer to, B would admit every caller on such a kernel (fail-open, the opposite of the card's restrictive divergence), and C trades a guarantee for third-party ISecurityService implementations for one read. A keeps a documented, pinned (degraded-kernel suites in both gate tests; ablations G3 and C3) restrictive path and changes no behaviour the ruling did not order.

Ruling item 3 — honoured in order: measured first on a real stack in both postures (52 cells, 4 forks, all on-behalf-of callers, both directions), the explain path fixed (7da6868), re-measured at 52/52, then the two steps dropped (ba6fb49). Neither forbidden arm taken: the steps were not dropped over a fork, and A was not fallen back to.

Ruling item 2's pointer on #7144 — not in the diff; the PR names it as the seat's write after landing. Owed by the seat, not a defect of this head.

Ruling item 4's declaration to the spec seat — packages/spec/src/contracts/sharing-service.ts is declared cross-lane in the claim (6282082958), as required.

Pre-registered pin from #24647 — flipped as pre-registered (title and assertion now "the parent gates' one question, the by-id update verdict, admits the row the by-id update admits"), kept load-bearing on the real plugin-audit gate; the refusing row still refuses RECORD_NOT_ACCESSIBLE / 403.

Not measured (delegated READ verdict) — recorded; ruling B asks only the update verdict, and the binding states the limit in place.

Security-class out-of-scope finding (the write door's own on-behalf-of composition under an app-authored row policy) — outside this card; this PR neither changes nor widens that answer, explain now matches the door there so the gates do too, and the dev reports it to the seat with the reproduction kept off public surfaces. This record states no more than that.

Implemented-by: claude/issue-24646-parent-gates-one-question
Reviewed-by: session_011TdZ8GjixNZFLz1xJD9zzK

VERDICT: PASS

Rendered by an isolated subagent of domain:services seat 1 at CONTRACT_REVIEW_TIER, read-only, fed only the card, the PR and the head's check-runs; adopted by the seat · 2026-10-20T04:17Z.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T04:39Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 580fd4ed3c, not against the report (os-dev-report 6283091147 on #24646). This is the TIER_DEFAULT remainder: the CONTRACT_REVIEW_TIER record for packages/spec/src/contracts/sharing-service.ts and the hunks it cites is a separate comment, 6283270561 (PASS on this head, rendered by an isolated subagent at that tier and adopted by the seat).

Disclosure read first. The PR body describes the explain-vs-door fork this PR closes, in mechanism terms, and the out-of-scope door finding only abstractly. Its reproduction stays off every public surface: this seat holds it privately and has filed it as #24746. Head under review:

d35ec05d557a6d9f0ac74ed9a1f691f7dca85865
  1. Shape. Draft, base main, first line Fixes #24646, line-start Clause-②: yes in the body. The body names Decision needed: should the sys_comment parent gates run the parent's owner-match at the caller's real write DEPTH, or stay at own? #7144, security(attachments, comments): authorization defect in the sys_attachment / sys_comment parent gates — detail withheld pending maintainer #24549, fix(service-storage,plugin-audit): the attachment and comment parent gates give the answer a by-id update of the parent gets #24641 and security(explain): explain's record-grained update verdict refuses a row an app-authored update policy admits, while the PATCH of that row is admitted (ADR-0105 D3 deferral missing on explain's record path) #24647 with no keyword beside any of them; every other "fixes" / "resolve" in the body is prose with no issue number next to it. 11 files, +987/−349, none on a governed surface (governedPathsIn answers []).

    The trial merge onto origin/main 580fd4ed3c is clean. Since the merge base, main has moved by feat(plugin-security): organization administrators assign positions and permission sets inside the D12 organization boundary #24732 (explainAccessForCaller in security-plugin.ts, a different function from the explain record binding here) feat(cli,plugin-audit): os migrate audit-mail-bodies masks the mail bodies that pre-#24688 audit and activity copies still hold in clear #24722 (plugin-audit mail bodies), feat(objectql,cli,plugin-webhooks)!: 18.x step 4: the walled lift (8b walled half, the os serve lift, the declared-webhook name guard, declared webhooks deployment-wide) #24667 and fix(plugin-sharing,plugin-webhooks): by_object lists sort by object instead of grouping on its API name #24733 (neither touches plugin-sharing's write middleware, which the explain binding mirrors). The merge group builds the combination.

  2. Ruling item 3, then the fork arm, read in the code.

    • On-behalf-of callers: delegatedWriteContexts / delegatedWriteGate in the explain binding build exactly the two contexts the write path hands record sharing.
      • The agent leg carries step 2.6's __writeScope from intersectDelegatedScope(declared, delegatorScope).agentLegScope plus __delegatorWriteScope; this matches security-plugin.ts step 2.6 on this head.
      • The delegator leg is userId set to the delegator, no onBehalfOf, and __writeScope set to the delegator's depth; this matches plugin-sharing/src/sharing-plugin.ts:1336–1343's second gate call.
    • The engine ANDs the delegator's write filter (computeRlsFilter over the delegator's sets and context, the call step 2.7 makes) into the record's Layer 1 for update / delete, and a fault there fails closed on rls.
    • Ordinary callers keep withWriteScope byte-for-byte.
    • The 52-cell table (48/4 → 52/0) and ablations A1 and A2 (8 and 6 red) carry it.
  3. The two gates, read in the code.

    • Where explain is served, canEditParent / mayEditAttachmentParent ask only security.explain({ operation: 'update', recordId }), admitting allowed === true && record.visible === true. A throw propagates.
    • Where explain is not served, or there is no security service, both call the unchanged sharingAndMasterAdmit (checkEdit at own, then the master check; with no sharing service, the caller-scoped read alone). That is the seat's degraded-kernel constraint, and G3 and C3 hold it (52 and 4 red).
    • With explain served but no sharing service, the caller-scoped read is still asked first.
    • Refusals assert the ADR-0112 envelope. The pre-registered pin at explain-enforce-parity.test.ts is flipped and stays load-bearing on the real sys_comment gate (C5, 5 red).
  4. Prose read sentence by sentence against the diff.

    • .changeset/24646-parent-gates-one-question.md:
      • the item 5 sentence, verbatim;
      • the widened surface list (attach, detach, edit another user's attachment, re-point, assertMayAttach, comment moderation);
      • "no longer ask record sharing at owner-only depth first, nor the master-detail check";
      • the unchanged codes (ATTACHMENT_PARENT_ACCESS and ATTACHMENT_DELETE_DENIED in the storage gate, RECORD_NOT_ACCESSIBLE in both);
      • "A store fault keeps its own status";
      • the degraded-kernel bullet;
      • the spec bullet ("No type, member or export changes").
    • .changeset/24646-explain-on-behalf-of-write-verdict.md: "asked twice … agent-leg write depth, then as the delegator"; "ANDed beside the principal's"; "Before, explain asked the gate once at owner-only depth" (the on-behalf-of withWriteScope passed the context unstamped); "the delegated read verdict" is unaffected (the read binding is unchanged).
    • content/docs/permissions/attachments-access.mdx: the new "Create — parent-edit access" paragraph and the degraded-kernel paragraph. Each sentence matches the code in 3.
    • Semver: minor for service-storage, plugin-audit and spec (ruling item 4); patch for plugin-security, which fixes explain's answer.
  5. Gates. At ACCEPT, every check-run on this head is success or an expected skip, including TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core and Governed Surface Queue Guard. Locally, all 114 derived families exited 0 at this head (--ran: 114 derived, 114 run).

  6. Accepted deviations. The claim surface is amended to cover:

    • content/docs/permissions/attachments-access.mdx: this change made its parent-edit section false;
    • the second changeset;
    • the explain binding and engine, the conditional surface the claim named for ruling item 3's fork, which the measurement took;
    • delete beside update on the fork arm, which shares the per-record gate and the step 2.7 path, each with its own rows;
    • one merge of main before the PR.

    The commit trailers are model-free, per AGENTS.md.

  7. Open question (degraded kernels): option A, as implemented. It is the seat's dispatch constraint. Business need: every shipped kernel composes plugin-security, which serves explain. Long-term soundness: one rule where a judgement exists, and no fail-open where none does. Preventing AI errors: a custom security service without explain cannot silently open either gate. Startup focus: no new surface.

  8. Out-of-scope dispositions.

  9. Landing clause. This is Tier S: not governed, under the human-merge threshold. The CONTRACT_REVIEW_TIER record cited above is a PASS on this head. After landing, the seat posts the pointer on Decision needed: should the sys_comment parent gates run the parent's owner-match at the caller's real write DEPTH, or stay at own? #7144 (ruling item 2). Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 04:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 04:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 1f949da Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24646-parent-gates-one-question branch October 20, 2026 05:15
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