Repository navigation
feat(service-storage, plugin-audit): the parent gates ask the parent's by-id update alone; explain composes the on-behalf-of write - #24745
Conversation
…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>
…rent-gates-one-question
…dmit-or-envelope Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 150 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: ① Derived judgmentsNet diff read at merge-base
Gate verdicts on ② Semver level
③ Boundary flagsDev report 6283091147 — deviations, each answered:
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 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 — 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 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: VERDICT: PASS Rendered by an isolated subagent of Generated by Claude Code |
席位核对 · ACCEPT ·
|
Fixes #24646
Clause-②: yes
The
sys_attachmentparent gate and thesys_commentmoderation gate now ask one question about the parent record: the security service's record-grained judgement of a by-idupdateof it. Ruling item 3 made a measurement the precondition. It found thatsecurity.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
Item 3: the on-behalf-of measurement, and the fork it found
Rig. The real
SecurityPlugin, a realObjectQLoverbetter-sqlite3, the realSharingServiceand its middleware, and real platform identity objects. Each caller's context is assembled by the realresolveAuthzContextandassembleExecutionContext. The on-behalf-of caller is an OAuth agent with themcp_agent_data_writeceiling acting for the user. There are three users: anorg-write-depth delegator, anown-write-depth delegator, and a third user who owns the rest. There are three objects: aprivateparent with owner and creator columns, aprivatemaster, and acontrolled_by_parentdetail. 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, andsingle, which is wall-less), for 52 cells. Each cell comparessecurity.explain({ object, operation: 'update', recordId })against the by-id PATCH of the same record.ec2694dc19, before the explain changeAll 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: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.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
@objectstack/plugin-security: explain composes the on-behalf-of by-id write (security-plugin.tsexplain binding,explain-engine.tsrecord verdict).update/deletegate 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.update/deleteANDs the delegator's own write filter (computeRlsFilterover 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, onrls.attachment-access-hooks.ts,comment-access-hooks.ts). Wherever the security service servesexplain, "may edit the parent" is its record-grainedupdateverdict for the parent alone (allowed === true && record.visible === true). Record sharing'scheckEditand 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.explain, or no security service at all, judges no by-id update. There the gate keeps record sharing atown, then the master-detail check on an abstention. With no sharing service, the caller-scoped parent read stands alone.explainserved, the parent read is still asked first and the judgement second, as today.explainwould admit every caller. Both module headers say so, and a degraded-kernel suite in each gate's tests holds it.packages/spec/src/contracts/sharing-service.ts, prose only.sys_commentparent gates run the parent's owner-match at the caller's real write DEPTH, or stay atown? #7144 ruling is superseded for them.resolveWriteScopefail-open as the reason the gates never resolve a depth themselves.canEditsentence that pointed at it is rewritten to match.check:generatedreports all 14 artifacts up to date: the prose feeds none of them.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..changeset/24646-parent-gates-one-question.md:minorfor@objectstack/service-storage,@objectstack/plugin-auditand@objectstack/spec,Clause-②: yes (widening), with ruling item 5's sentence..changeset/24646-explain-on-behalf-of-write-verdict.md:patchfor@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, underisolatedandsingle, 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:
deny;visible: true;sys_commentmoderation 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:
orgwriter on someone else's row (the widening);controlled_by_parentdetail judged through its master, admitted under an editable master and refusedRECORD_NOT_ACCESSIBLE/ 403 under a master the caller reads but may not edit;PERMISSION_DENIED/ 403;Gate units (
attachment-access-hooks.test.ts,comment-parent-update-parity.test.ts). Each gate has 10 shapes on every limb. Whereexplainis served, the gate answers exactly what the judgement answers, andcheckEditand the master check are never called. Two rows show the change itself:Refusals assert the ADR-0112 envelope (
codeandstatus). A degraded-kernel suite holds today's answers whereexplainis not served.Ablations
Every ablation was run from committed HEAD, through
scripts/ablation-replace.mjsin wrap mode with a trap restore. In every case the anchor moved 1 → 0, the restored blob equals the HEAD blob, andgit diff HEADis empty. The subjects resolve to source (the vitest alias for@objectstack/plugin-audit), so there is nodistleg.explainadmitsexplainadmitsorg-writer and agent cells, both posturesTests
Package suites ran at
79c3d98b98, the merge oforigin/main9b2953d62c. The only later commit,d35ec05d55, is a type annotation in one test file. Both commands for that file were re-run atd35ec05d55.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 atd35ec05d55. It had found 2 errors at79c3d98b98, in the new cells' expected-answer type, andd35ec05d55fixes them.explain-enforce-parity.test.ts: 150 passed atd35ec05d55.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/objectstackderives 114 families from this diff (merge-baseorigin/main9b2953d62c). I ran all 114 atd35ec05d55, each exit code captured before any pipe, and all 114 exited 0.The first run, at
79c3d98b98, had three families refuse withPREREQUISITE NOT MET(exit 3), and none of the three measured anything:check:skill-examples(client SDK dists),check:dual-build-cjs-loads(package dists) andcheck:i18n(the CLI build closure). Their prerequisites were built (turbo run buildof that closure), and then the whole set was run again on the final head.--ranreconciliation: "114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0). The local-onlypnpm check:dev-prereqsalso exits 0, since this diff touchespackages/spec. CI runs the full battery.Acceptance notes
explainis served (point 3 above). The dev report puts this inopen_questions.sys_commentparent gates run the parent's owner-match at the caller's real write DEPTH, or stay atown? #7144 is the seat's write after landing.sys_commentparent gates run the parent's owner-match at the caller's real write DEPTH, or stay atown? #7144 (the superseded ruling), security(attachments, comments): authorization defect in the sys_attachment / sys_comment parent gates — detail withheld pending maintainer #24549 and PR fix(service-storage,plugin-audit): the attachment and comment parent gates give the answer a by-id update of the parent gets #24641 (the gates' update judgement), 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 (explain's authored-row-write deferral, which this PR's flipped pin builds on).Generated by Claude Code