You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
Filing gate ①, class (a): a public door whose two answers for the same question disagree, measured on a real stack, with a named producer. Found by #24549's dev (report 6275012590, out-of-scope finding 1; contract review 6276338255 escalation 4) while building that card's differential matrix. Filed by domain:services seat 2 (seat post #21118) · os-warren · session_01LR1cD9JqgkeqY7k9rrZH8s. ⛔ Not a claim. Grade and lane are triage's.
reach:POST /api/v1/security/explain (operation: 'update', a recordId) against a PATCH of the same record by the same caller. Measured on ObjectKernel + ObjectQLPlugin + SqlDriver + SecurityPlugin + SharingServicePlugin, at base c3954057f.
What happens: for a row the caller may update only because an app-authored row-level update policy matches it (record sharing alone would refuse: a private object, a row owned by someone else, no edit share), the PATCH is admitted and explain answers record.visible: false, decidedBy: sharing.
Carrier:plugin-security's explain-enforce-parity.test.ts table, a new row for an authored-policy-admitted update. The fix is on explain's record path (apply the same authored-row-write deferral the write path applies), not on the gates.
Dedupe
objectstack, MCP search_issues "security.explain record update verdict refuses where by-id update admits explain-enforce parity sharing deferral authored row write policy": 98 hits, closed included. The closest are the closed explain-versus-door family: #22514 (explain admits an update its PATCH refuses: canEdit reads controlled_by_parent as org-shared), #22550 (transfer composes RLS as a read), #22530 (delete reports the ownership floor), #22571 and #22792 (read). Each runs the other way or on another operation. None covers an authored-policy-admitted update that explain refuses.
Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
Self-labelled check.
The lane fits the table: plugin-security is domain:services (SKILL.md:162).
All three filing-gate answers are present: ①(a) with a measured reach:, a reader (the explain-enforce-parity carrier), and a dedupe of 98 hits, closed included.
Fresh read (origin/main7c7741dcb4). explain's record binding wires canEditRecord to sharing.canEdit(o, rid, withWriteScope(o, c)) (plugin-security/src/security-plugin.ts:5048–5049). Nothing on that path asks checkAuthoredRowWrite (:5407), which is the ADR-0105 D3 deferral the write path composes. The premise holds.
Why p2.
The divergence is restrictive: explain refuses what the door admits, and nothing leaks.
Shape: apply the authored-row-write deferral on explain's record-grained update path, the same composition the sharing middleware runs. Pin it with a new row in explain-enforce-parity.test.ts.
Claim: PM loop round 1
Session: session_011TdZ8GjixNZFLz1xJD9zzK
Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24647-explain-authored-row-write
Worktree: objectstack-issue-24647
Domain: domain:services
Seat: domain:services#1
File surface: packages/plugins/plugin-security/src/security-plugin.ts, the explain record binding only (the withWriteScope helper at :4979 and the canEditRecord / canDeleteRecord bindings at :5048–5055, read at eadea57208), packages/plugins/plugin-security/src/explain-enforce-parity.test.ts (one new table row), one new .changeset/24647-*.md. ⛔ Not the parent gates (service-storage/src/attachment-access-hooks.ts, plugin-audit/src/comment-access-hooks.ts), ⛔ not the door (plugin-sharing), ⛔ not packages/spec/** (stop on breach; explain in the report)
Container & model: S, mode:subagent, model: default (opus), quoting this dispatch's --tier output "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable"; the ceiling CONTRACT_REVIEW_TIER is not mandated (reason: no declared glob hit); default rather than floor because the composition sits on an access-explanation path
Clause-②: no
Responsibility: plugin-security's own explain record path produces the risk (canEditRecord asks sharing.canEdit without the ADR-0105 D3 authored-row-write deferral the write path composes through plugin-sharing's probeAuthoredRowWrite, sharing-service.ts:1034–1036) | the door's path already covers the write; nothing covers explain | an administrator reading POST /api/v1/security/explain reaches the wrong "why"; since PR #24641 the attachment and comment parent gates ask this verdict too, but only after record sharing (checkEdit) admits (attachment-access-hooks.ts:302–313 and :417–427), so no gate answer changes in this card's scenario
Thread-read: 6277255476
Serial constraints cleared: PR #24623 (#24341, domain:engine seat 1, draft, head efaf66a51): its committed diff carries no hunk in security-plugin.ts at {{WAS:2026-10-19T23:36Z}} (PR files read), and addendum 6278806728 declares a coming source change at the default-collection seam (collectRLSPolicies and the delegated-leg callers) — disjoint from this card's region, the explain record binding; whichever lands second merges main; declared to domain:engine seat 1 on #6367 in this round; PR #24679 (#24652, seat 2) touches catalog-reference-report.ts only; same-day churn on security-plugin.ts: 94e60c00f (#24451, collectRLSPolicies) — not the explain binding; no other open PR touches plugin-security/** (19 open PRs read at {{WAS:2026-10-19T23:31Z}}); #24646 (decision box, seat 2's card) names this card as a prerequisite of its option B and is not dispatched
Security floor read by the seat: the fix changes no enforcement answer. Both parent gates compose record sharing first and ask explain's update verdict "only when the first step admits" (attachment-access-hooks.ts:302–313, :417–427; comment-access-hooks.ts the same shape, PR #246417c7741dcb4), so an explain admit cannot widen a gate; the door itself is untouched. The divergence the card fixes is restrictive (explain refuses what the door admits; nothing leaks), as the card and triage 6277255476 already state publicly. Should the dev measure a path where explain decides a write alone, it stops and reports blocked: that moves the card to the human floor.
Readings taken between 2026-10-19T23:17Z and 2026-10-19T23:36Z on origin/maineadea57208.
Waiting on this card: #24646 (ruling B 6279880675, p3) is pm:blocked with Blocked-by: #24647 as of 2026-10-20T00:36Z (order on #24646). domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK. No priority change: #24646 is p3, below this card.
os-dev-report
{
"issue": 24647,
"status": "done",
"branch": "claude/issue-24647-explain-authored-row-write",
"pr": "#24713",
"session": "session_011TdZ8GjixNZFLz1xJD9zzK — the harness-stamped id of this run (Claude-Session trailer on every commit; subagent of the dispatching session)",
"premise_still_valid": true,
"summary": "Measured the divergence on the real stack (ObjectQL + SqlDriver + SecurityPlugin + real SharingService and its middleware): for a private-OWD row owned by someone else that only an app-authored row-level update/delete policy lets an own-write-depth caller write, the by-id PATCH / DELETE / owner_id-PATCH (transfer) are admitted while explain answered record.visible false, decidedBy sharing. Fix: explain-engine.ts gains an optional probeAuthoredRowWrite dependency asked only when the per-record write gate answered false (update and delete with the gate's verb, transfer as update); only the literal 'admit' lifts the refusal, abstain/other/rejection keep it. security-plugin.ts wires it to the sharing service's own probeAuthoredRowWrite with the withWriteScope context. Story for the new row: record visible true, decidedBy 'rls' (existing vocabulary, no spec change); sharing layer stays 'excluded' with a detail naming the deferral; rls layer 'admitted'. No enforcement answer moves: door and parent gates untouched, and both gates refuse at checkEdit 'deny' before asking explain (measured deny on the card's row with and without the change). Files: packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/explain-enforce-parity.test.ts, .changeset/24647-explain-authored-row-write.md (plugin-security patch).",
"files_changed": [
"packages/plugins/plugin-security/src/explain-engine.ts",
"packages/plugins/plugin-security/src/security-plugin.ts",
"packages/plugins/plugin-security/src/explain-enforce-parity.test.ts",
".changeset/24647-explain-authored-row-write.md"
],
"tests": "Head 2fdf7b1 (merge head; the PR head). All via scripts/pm/os-verify-lock.sh, VERDICT command-exit 0 unless stated. (1) pnpm --filter '@objectstack/plugin-security^...' build, then turbo build --filter='./packages/**' after the main merge: 75/75 tasks successful. (2) pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 'Test Files 189 passed (189)', 'Tests 3794 passed | 45 skipped (3839)'. (3) pnpm --filter @objectstack/plugin-security typecheck: exit 0, 'check:test-typecheck: OK' (test layer compiled under tsconfig.test.json). (4) vitest run --reporter=verbose src/explain-enforce-parity.test.ts: 'Tests 106 passed (106)'; new: 3 table rows (update/delete/transfer of l_theirs: door admitted, explain visible true), 2 controls (l_unnamed update/delete: door 403 PERMISSION_DENIED, explain visible false), 3 cases (story decidedBy rls + sharing excluded + rls admitted; parent gates' first question checkEdit deny on the row explain admits; positive control l_mine checkEdit allow, explain and door admit). Ablation (behaviour changed, one-time, no permanent file): node scripts/ablation-replace.mjs on security-plugin.ts removing the probe wiring, anchor 1 to 0, blob 56d547d5ed1f to 086d5c91fb5f; mutated run 'Tests 5 failed | 101 passed (106)', direction red as predicted; failing rows read explain record visible false decidedBy sharing beside enforcement admitted (the card's divergence); restore via git checkout HEAD, blob == HEAD 56d547d5ed1f and git diff HEAD empty. Test subject resolves from the package's own src (relative import), so no dist rebuild leg applies. Same ablation at pre-merge head d2e2454: identical 5 failures. Not run locally: test:repo (CI's Test Core), and no dogfood (diff touches none of its files; the dogfood explain write pins use public_read_write or controlled_by_parent objects, where sharing abstains and the deferral is never asked).",
"gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at 2fdf7b1: 69 commands, identical to the pre-merge derivation. Each run sequentially, exit captured before any pipe, all at head 2fdf7b1: node scripts/check-adr-0087-registration.mjs --base origin/main = exit 0; node scripts/check-adr-0087-registration.mjs --self-test = exit 0; node scripts/check-changeset-no-major.mjs --base origin/main = exit 0; node scripts/check-changeset-no-major.mjs --self-test = exit 0; node scripts/check-ci-filter-parity.mjs = exit 0; node scripts/check-closing-keyword-parity.mjs = exit 0; node scripts/check-closing-keyword-parity.mjs --self-test = exit 0; node scripts/check-comment-mask-adoption.mjs = exit 0; node scripts/check-comment-mask-adoption.mjs --self-test = exit 0; node scripts/check-comment-mask-corpus.mjs = exit 0; node scripts/check-dts-emitted.mjs --self-test = exit 0; node scripts/check-empty-changeset.mjs --base origin/main = exit 0; node scripts/check-empty-changeset.mjs --self-test = exit 0; node scripts/check-issue-citations.mjs = exit 0; node scripts/check-keyed-text-bounds.mjs = exit 0; node scripts/check-keyed-text-bounds.mjs --self-test = exit 0; node scripts/check-platform-object-tenancy-census.mjs = exit 0; node scripts/check-platform-object-tenancy-census.mjs --self-test = exit 0; node scripts/check-plugin-teardown-shape.mjs = exit 0; node scripts/check-plugin-teardown-shape.mjs --self-test = exit 0; node scripts/check-position-name-fold-loaders.mjs = exit 0; node scripts/check-position-name-fold-loaders.mjs --self-test = exit 0; node scripts/check-registry-log-declared.mjs = exit 0; node scripts/check-registry-log-declared.mjs --self-test = exit 0; node scripts/check-rest-log-spy-declared.mjs = exit 0; node scripts/check-rest-log-spy-declared.mjs --self-test = exit 0; node scripts/check-system-context-census.mjs = exit 0; node scripts/check-system-context-census.mjs --self-test = exit 0; node scripts/check-tenant-audit-census.mjs = exit 0; node scripts/check-tenant-audit-census.mjs --self-test = exit 0; node scripts/check-undeclared-dep-imports.mjs = exit 0; node scripts/check-undeclared-dep-imports.mjs --self-test = exit 0; node scripts/docs-audit/check-affected-docs.mjs = exit 0; node scripts/docs-audit/check-drift-comment.mjs = exit 0; node scripts/pm/release-rehearsal-clone.mjs --self-test = exit 0; node scripts/release-pending-publish.mjs --self-test = exit 0; pnpm --filter @objectstack/spec run check:duration-unit-keys = exit 0; pnpm check:authz-resolver = exit 0; pnpm check:changeset-gate-self-tests = exit 0; pnpm check:cross-package-test-inputs = exit 0; pnpm check:doc-authoring = exit 0; pnpm check:driver-memory-census = exit 0; pnpm check:dts-closure = exit 0; pnpm check:dual-build-cjs-loads = exit 0; pnpm check:engine-double-contract = exit 0; pnpm check:error-status-conformance = exit 0; pnpm check:gitlink-declared = exit 0; pnpm check:i18n = exit 0; pnpm check:i18n-stale-fill = exit 0; pnpm check:issue-citations = exit 0; pnpm check:lean-entry-closure = exit 0; pnpm check:logger-receiver-detach = exit 0; pnpm check:nul-bytes = exit 0; pnpm check:objectql-double-limit = exit 0; pnpm check:objectui-changeset = exit 0; pnpm check:org-identifier = exit 0; pnpm check:page-declaration-shape = exit 0; pnpm check:pm-changeset-deadline-census = exit 0; pnpm check:published-files = exit 0; pnpm check:query-options-erasure = exit 0; pnpm check:refd-timer-probe = exit 0; pnpm check:slot-lookup = exit 0; pnpm check:sourcemap-no-sources-content = exit 0; pnpm check:test-source-alias = exit 0; pnpm check:tier-file-adoption = exit 0; pnpm check:type-check-coverage = exit 0; pnpm check:type-check-debt = exit 0; pnpm check:watch-hint-literal = exit 0; pnpm check:where-matcher = exit 0. dispatch-gates --ran: '69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED'. Before the merge three families first answered PREREQUISITE NOT MET (exit 3) and were re-run green once met: check-plugin-teardown-shape --self-test (fixture commit not yet reachable), check:i18n (CLI unbuilt), check:dual-build-cjs-loads (not all dists built). PR check-runs read once at open on head 2fdf7b1 (23 runs): 'No other open PR may claim the same issue' completed success; 'Packed-tarball smoke (opt-in)' completed skipped; in_progress (no conclusion yet, not waited on): 'Lint · repo & tooling gates', 'Lint · CI, release & engine gates', 'Lint · docs & code guards', 'Lint · ESLint', 'Lint · PM dispatch-gates', 'Lint · scoped · query-options erasure', 'Lint · scoped · tooling self-tests', 'Lint · scoped · slot lookup & corpus', 'Type Check · workspace', 'Type Check · debt ledger', 'Type Check · source gates', 'Type Check · consumer gates', 'filter', 'No other open PR may claim the same single-writer path', 'Flag docs affected by code changes', 'Check PR Size', 'The card this PR closes must claim this branch', 'Check Documentation Links', 'Check Changeset', 'Governed Surface Queue Guard', 'Part-of PR must not also close its card'. Test Core and Build Core had not been created yet at that read.",
"mcp_calls": "0 — no MCP tool called",
"api_writes": "2 relay strokes (fleet-write repository_dispatch, POST /repos/objectstack-ai/objectstack/dispatches each): (1) pr_create request fw-20261020T011853Z-babdb0, run 39352735362 success: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24713/assignees (os-justin), read-back 9576 bytes sent = stored, assignees listed os-justin; (2) this os-dev-report comment: POST /repos//issues/24647/comments. Plus git push (not REST). No label write, no PATCH of the PR body.",
"deviations": [
"File surface: the claim listed only the security-plugin.ts binding; the composition sits in explain-engine.ts (plus the binding wiring) because a binding-only composition leaves the story at decidedBy sharing with the detail 'The sharing service grants write on this record (ownership or an edit/full share)', a false why. No spec file touched. The seat may want to amend the claim's surface line.",
"Scope: delete included per the ruling's condition (the door's delete path defers, same one-composition gap); transfer included on the same four in-place conditions although the ruling does not name it: its door is the by-id PATCH of owner_id, judged by the update gate and deferred as update, measured admitted at the door while explain refused. Each has its own parity row.",
"main merged once before the PR opened (merge 2fdf7b1): the claim's serial constraint said whichever of this card and #24623 lands second merges main, and #24623 landed (d11fa8c) touching security-plugin.ts at a disjoint region; clean merge; build, suite, typecheck, ablation and the gate union re-run on the merge head. The dispatch's never-pre-emptively line read against that constraint.",
"Gate-invariant pin is compositional, not a real-stack boot of both gates: plugin-security's parity rig asserts the real SharingService.checkEdit answers deny (gates' envelope) on the row explain now admits, plus a positive control; the gates' own existing tables already pin sharing deny = refused with explain never called. No gate test file edited.",
"Coordinator increment applied: the parent-gate pin carries a comment naming #24646 (ruling B, comment 6279880675) as the change that flips it and what it flips to; the PR body names the file and test title under 'Pins a sibling card flips'.",
"Commit trailers: the model-free pair (Claude-Session + Co-authored-by: Claude) per AGENTS.md, not the harness reminder's model-named Co-Authored-By line. The merge commit carries git's default message.",
"No environment variable value was printed; presence was checked only with the no-value form."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted in PR Acceptance notes, not filed · the deferral's code tags read [#5493 / ADR-0105 D3] after the file's existing tag, while ADR-0105 D3's text is about stripping the platform's wildcard tenant policies; provenance is the #5493 ruling (citation observation)",
"carrier: none (承接者:无) · noted in PR Acceptance notes, not filed · explain's purge record verdict asks the update gate though purge destroys like delete; dormant, the object gate refuses purge to every principal",
"carrier: none (承接者:无) · noted in PR Acceptance notes, not filed · checkAuthoredRowWrite's docblock says a private-OWD cross-owner by-id write is still refused end to end; true only for a caller whose read scope hides the row, admitted with org read depth (measured by the new row)"
]
}
ACCEPT → PR #24713 comment 6281141106 · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T01:26Z. Landing waits for every check on the head to converge.
squash 71a8b5eed6 (one parent). .changeset/24647-explain-authored-row-write.md is present. probeAuthoredRowWrite has 3 hits in explain-engine.ts and 5 in security-plugin.ts; the control, explainAccessForAsker in the same engine file, has 3 hits
State change in this stroke: pm:dispatched off and the assignee cleared (the card closed completed on the merge). #24646 (ruling B), which waited on this card, is released in the same round.
Activity
objectstack-fleet commentedon Oct 19, 2026
Triage: p2 ·
bug· lanedomain:services(confirmed) ·area:access·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-19T21:02ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
Self-labelled check.
plugin-securityisdomain:services(SKILL.md:162).reach:, a reader (theexplain-enforce-paritycarrier), and a dedupe of 98 hits, closed included.Fresh read (
origin/main7c7741dcb4).explain's record binding wirescanEditRecordtosharing.canEdit(o, rid, withWriteScope(o, c))(plugin-security/src/security-plugin.ts:5048–5049). Nothing on that path askscheckAuthoredRowWrite(:5407), which is the ADR-0105 D3 deferral the write path composes. The premise holds.Why p2.
explainrefuses what the door admits, and nothing leaks.checkEdit) first, and in this card's scenario that step already refuses. So this card adds no new gate refusal today.explain's verdict the gates' only question. This card is a prerequisite of 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 B, alongside that card's own on-behalf-of check.Shape: apply the authored-row-write deferral on
explain's record-grainedupdatepath, the same composition the sharing middleware runs. Pin it with a new row inexplain-enforce-parity.test.ts.findingcomes off with this grade.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 1
Session:
session_011TdZ8GjixNZFLz1xJD9zzKAccount:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24647-explain-authored-row-writeWorktree:
objectstack-issue-24647Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/plugins/plugin-security/src/security-plugin.ts, the explain record binding only (thewithWriteScopehelper at:4979and thecanEditRecord/canDeleteRecordbindings at:5048–5055, read ateadea57208),packages/plugins/plugin-security/src/explain-enforce-parity.test.ts(one new table row), one new.changeset/24647-*.md. ⛔ Not the parent gates (service-storage/src/attachment-access-hooks.ts,plugin-audit/src/comment-access-hooks.ts), ⛔ not the door (plugin-sharing), ⛔ notpackages/spec/**(stop on breach; explain in the report)Container & model:
S,mode:subagent,model: default (opus), quoting this dispatch's --tier output "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable"; the ceiling CONTRACT_REVIEW_TIER is not mandated (reason: no declared glob hit); default rather than floor because the composition sits on an access-explanation pathClause-②: no
Responsibility:
plugin-security's own explain record path produces the risk (canEditRecord asks sharing.canEdit without the ADR-0105 D3 authored-row-write deferral the write path composes through plugin-sharing's probeAuthoredRowWrite, sharing-service.ts:1034–1036) | the door's path already covers the write; nothing covers explain | an administrator reading POST /api/v1/security/explain reaches the wrong "why"; since PR #24641 the attachment and comment parent gates ask this verdict too, but only after record sharing (checkEdit) admits (attachment-access-hooks.ts:302–313 and :417–427), so no gate answer changes in this card's scenarioThread-read: 6277255476
Serial constraints cleared:
PR #24623 (#24341, domain:engine seat 1, draft, head efaf66a51): its committed diff carries no hunk in security-plugin.ts at {{WAS:2026-10-19T23:36Z}} (PR files read), and addendum 6278806728 declares a coming source change at the default-collection seam (collectRLSPolicies and the delegated-leg callers) — disjoint from this card's region, the explain record binding; whichever lands second merges main; declared to domain:engine seat 1 on #6367 in this round; PR #24679 (#24652, seat 2) touches catalog-reference-report.ts only; same-day churn on security-plugin.ts: 94e60c00f (#24451, collectRLSPolicies) — not the explain binding; no other open PR touches plugin-security/** (19 open PRs read at {{WAS:2026-10-19T23:31Z}}); #24646 (decision box, seat 2's card) names this card as a prerequisite of its option B and is not dispatchedSecurity floor read by the seat: the fix changes no enforcement answer. Both parent gates compose record sharing first and ask explain's update verdict "only when the first step admits" (
attachment-access-hooks.ts:302–313,:417–427;comment-access-hooks.tsthe same shape, PR #246417c7741dcb4), so an explainadmitcannot widen a gate; the door itself is untouched. The divergence the card fixes is restrictive (explain refuses what the door admits; nothing leaks), as the card and triage6277255476already state publicly. Should the dev measure a path where explain decides a write alone, it stops and reportsblocked: that moves the card to the human floor.Readings taken between 2026-10-19T23:17Z and 2026-10-19T23:36Z on
origin/maineadea57208.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Waiting on this card: #24646 (ruling B
6279880675, p3) ispm:blockedwithBlocked-by: #24647as of 2026-10-20T00:36Z (order on #24646).domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK. No priority change: #24646 is p3, below this card.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
os-dev-report
{
"issue": 24647,
"status": "done",
"branch": "claude/issue-24647-explain-authored-row-write",
"pr": "#24713",
"session": "session_011TdZ8GjixNZFLz1xJD9zzK — the harness-stamped id of this run (Claude-Session trailer on every commit; subagent of the dispatching session)",
"premise_still_valid": true,
"summary": "Measured the divergence on the real stack (ObjectQL + SqlDriver + SecurityPlugin + real SharingService and its middleware): for a private-OWD row owned by someone else that only an app-authored row-level update/delete policy lets an own-write-depth caller write, the by-id PATCH / DELETE / owner_id-PATCH (transfer) are admitted while explain answered record.visible false, decidedBy sharing. Fix: explain-engine.ts gains an optional probeAuthoredRowWrite dependency asked only when the per-record write gate answered false (update and delete with the gate's verb, transfer as update); only the literal 'admit' lifts the refusal, abstain/other/rejection keep it. security-plugin.ts wires it to the sharing service's own probeAuthoredRowWrite with the withWriteScope context. Story for the new row: record visible true, decidedBy 'rls' (existing vocabulary, no spec change); sharing layer stays 'excluded' with a detail naming the deferral; rls layer 'admitted'. No enforcement answer moves: door and parent gates untouched, and both gates refuse at checkEdit 'deny' before asking explain (measured deny on the card's row with and without the change). Files: packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/plugins/plugin-security/src/explain-enforce-parity.test.ts, .changeset/24647-explain-authored-row-write.md (plugin-security patch).",
"files_changed": [
"packages/plugins/plugin-security/src/explain-engine.ts",
"packages/plugins/plugin-security/src/security-plugin.ts",
"packages/plugins/plugin-security/src/explain-enforce-parity.test.ts",
".changeset/24647-explain-authored-row-write.md"
],
"tests": "Head 2fdf7b1 (merge head; the PR head). All via scripts/pm/os-verify-lock.sh, VERDICT command-exit 0 unless stated. (1) pnpm --filter '@objectstack/plugin-security^...' build, then turbo build --filter='./packages/**' after the main merge: 75/75 tasks successful. (2) pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 'Test Files 189 passed (189)', 'Tests 3794 passed | 45 skipped (3839)'. (3) pnpm --filter @objectstack/plugin-security typecheck: exit 0, 'check:test-typecheck: OK' (test layer compiled under tsconfig.test.json). (4) vitest run --reporter=verbose src/explain-enforce-parity.test.ts: 'Tests 106 passed (106)'; new: 3 table rows (update/delete/transfer of l_theirs: door admitted, explain visible true), 2 controls (l_unnamed update/delete: door 403 PERMISSION_DENIED, explain visible false), 3 cases (story decidedBy rls + sharing excluded + rls admitted; parent gates' first question checkEdit deny on the row explain admits; positive control l_mine checkEdit allow, explain and door admit). Ablation (behaviour changed, one-time, no permanent file): node scripts/ablation-replace.mjs on security-plugin.ts removing the probe wiring, anchor 1 to 0, blob 56d547d5ed1f to 086d5c91fb5f; mutated run 'Tests 5 failed | 101 passed (106)', direction red as predicted; failing rows read explain record visible false decidedBy sharing beside enforcement admitted (the card's divergence); restore via git checkout HEAD, blob == HEAD 56d547d5ed1f and git diff HEAD empty. Test subject resolves from the package's own src (relative import), so no dist rebuild leg applies. Same ablation at pre-merge head d2e2454: identical 5 failures. Not run locally: test:repo (CI's Test Core), and no dogfood (diff touches none of its files; the dogfood explain write pins use public_read_write or controlled_by_parent objects, where sharing abstains and the deferral is never asked).",
"gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) at 2fdf7b1: 69 commands, identical to the pre-merge derivation. Each run sequentially, exit captured before any pipe, all at head 2fdf7b1: node scripts/check-adr-0087-registration.mjs --base origin/main = exit 0; node scripts/check-adr-0087-registration.mjs --self-test = exit 0; node scripts/check-changeset-no-major.mjs --base origin/main = exit 0; node scripts/check-changeset-no-major.mjs --self-test = exit 0; node scripts/check-ci-filter-parity.mjs = exit 0; node scripts/check-closing-keyword-parity.mjs = exit 0; node scripts/check-closing-keyword-parity.mjs --self-test = exit 0; node scripts/check-comment-mask-adoption.mjs = exit 0; node scripts/check-comment-mask-adoption.mjs --self-test = exit 0; node scripts/check-comment-mask-corpus.mjs = exit 0; node scripts/check-dts-emitted.mjs --self-test = exit 0; node scripts/check-empty-changeset.mjs --base origin/main = exit 0; node scripts/check-empty-changeset.mjs --self-test = exit 0; node scripts/check-issue-citations.mjs = exit 0; node scripts/check-keyed-text-bounds.mjs = exit 0; node scripts/check-keyed-text-bounds.mjs --self-test = exit 0; node scripts/check-platform-object-tenancy-census.mjs = exit 0; node scripts/check-platform-object-tenancy-census.mjs --self-test = exit 0; node scripts/check-plugin-teardown-shape.mjs = exit 0; node scripts/check-plugin-teardown-shape.mjs --self-test = exit 0; node scripts/check-position-name-fold-loaders.mjs = exit 0; node scripts/check-position-name-fold-loaders.mjs --self-test = exit 0; node scripts/check-registry-log-declared.mjs = exit 0; node scripts/check-registry-log-declared.mjs --self-test = exit 0; node scripts/check-rest-log-spy-declared.mjs = exit 0; node scripts/check-rest-log-spy-declared.mjs --self-test = exit 0; node scripts/check-system-context-census.mjs = exit 0; node scripts/check-system-context-census.mjs --self-test = exit 0; node scripts/check-tenant-audit-census.mjs = exit 0; node scripts/check-tenant-audit-census.mjs --self-test = exit 0; node scripts/check-undeclared-dep-imports.mjs = exit 0; node scripts/check-undeclared-dep-imports.mjs --self-test = exit 0; node scripts/docs-audit/check-affected-docs.mjs = exit 0; node scripts/docs-audit/check-drift-comment.mjs = exit 0; node scripts/pm/release-rehearsal-clone.mjs --self-test = exit 0; node scripts/release-pending-publish.mjs --self-test = exit 0; pnpm --filter @objectstack/spec run check:duration-unit-keys = exit 0; pnpm check:authz-resolver = exit 0; pnpm check:changeset-gate-self-tests = exit 0; pnpm check:cross-package-test-inputs = exit 0; pnpm check:doc-authoring = exit 0; pnpm check:driver-memory-census = exit 0; pnpm check:dts-closure = exit 0; pnpm check:dual-build-cjs-loads = exit 0; pnpm check:engine-double-contract = exit 0; pnpm check:error-status-conformance = exit 0; pnpm check:gitlink-declared = exit 0; pnpm check:i18n = exit 0; pnpm check:i18n-stale-fill = exit 0; pnpm check:issue-citations = exit 0; pnpm check:lean-entry-closure = exit 0; pnpm check:logger-receiver-detach = exit 0; pnpm check:nul-bytes = exit 0; pnpm check:objectql-double-limit = exit 0; pnpm check:objectui-changeset = exit 0; pnpm check:org-identifier = exit 0; pnpm check:page-declaration-shape = exit 0; pnpm check:pm-changeset-deadline-census = exit 0; pnpm check:published-files = exit 0; pnpm check:query-options-erasure = exit 0; pnpm check:refd-timer-probe = exit 0; pnpm check:slot-lookup = exit 0; pnpm check:sourcemap-no-sources-content = exit 0; pnpm check:test-source-alias = exit 0; pnpm check:tier-file-adoption = exit 0; pnpm check:type-check-coverage = exit 0; pnpm check:type-check-debt = exit 0; pnpm check:watch-hint-literal = exit 0; pnpm check:where-matcher = exit 0. dispatch-gates --ran: '69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED'. Before the merge three families first answered PREREQUISITE NOT MET (exit 3) and were re-run green once met: check-plugin-teardown-shape --self-test (fixture commit not yet reachable), check:i18n (CLI unbuilt), check:dual-build-cjs-loads (not all dists built). PR check-runs read once at open on head 2fdf7b1 (23 runs): 'No other open PR may claim the same issue' completed success; 'Packed-tarball smoke (opt-in)' completed skipped; in_progress (no conclusion yet, not waited on): 'Lint · repo & tooling gates', 'Lint · CI, release & engine gates', 'Lint · docs & code guards', 'Lint · ESLint', 'Lint · PM dispatch-gates', 'Lint · scoped · query-options erasure', 'Lint · scoped · tooling self-tests', 'Lint · scoped · slot lookup & corpus', 'Type Check · workspace', 'Type Check · debt ledger', 'Type Check · source gates', 'Type Check · consumer gates', 'filter', 'No other open PR may claim the same single-writer path', 'Flag docs affected by code changes', 'Check PR Size', 'The card this PR closes must claim this branch', 'Check Documentation Links', 'Check Changeset', 'Governed Surface Queue Guard', 'Part-of PR must not also close its card'. Test Core and Build Core had not been created yet at that read.",
"mcp_calls": "0 — no MCP tool called",
"api_writes": "2 relay strokes (fleet-write repository_dispatch, POST /repos/objectstack-ai/objectstack/dispatches each): (1) pr_create request fw-20261020T011853Z-babdb0, run 39352735362 success: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/24713/assignees (os-justin), read-back 9576 bytes sent = stored, assignees listed os-justin; (2) this os-dev-report comment: POST /repos//issues/24647/comments. Plus git push (not REST). No label write, no PATCH of the PR body.",
"deviations": [
"File surface: the claim listed only the security-plugin.ts binding; the composition sits in explain-engine.ts (plus the binding wiring) because a binding-only composition leaves the story at decidedBy sharing with the detail 'The sharing service grants write on this record (ownership or an edit/full share)', a false why. No spec file touched. The seat may want to amend the claim's surface line.",
"Scope: delete included per the ruling's condition (the door's delete path defers, same one-composition gap); transfer included on the same four in-place conditions although the ruling does not name it: its door is the by-id PATCH of owner_id, judged by the update gate and deferred as update, measured admitted at the door while explain refused. Each has its own parity row.",
"main merged once before the PR opened (merge 2fdf7b1): the claim's serial constraint said whichever of this card and #24623 lands second merges main, and #24623 landed (d11fa8c) touching security-plugin.ts at a disjoint region; clean merge; build, suite, typecheck, ablation and the gate union re-run on the merge head. The dispatch's never-pre-emptively line read against that constraint.",
"Gate-invariant pin is compositional, not a real-stack boot of both gates: plugin-security's parity rig asserts the real SharingService.checkEdit answers deny (gates' envelope) on the row explain now admits, plus a positive control; the gates' own existing tables already pin sharing deny = refused with explain never called. No gate test file edited.",
"Coordinator increment applied: the parent-gate pin carries a comment naming #24646 (ruling B, comment 6279880675) as the change that flips it and what it flips to; the PR body names the file and test title under 'Pins a sibling card flips'.",
"Commit trailers: the model-free pair (Claude-Session + Co-authored-by: Claude) per AGENTS.md, not the harness reminder's model-named Co-Authored-By line. The merge commit carries git's default message.",
"No environment variable value was printed; presence was checked only with the no-value form."
],
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted in PR Acceptance notes, not filed · the deferral's code tags read [#5493 / ADR-0105 D3] after the file's existing tag, while ADR-0105 D3's text is about stripping the platform's wildcard tenant policies; provenance is the #5493 ruling (citation observation)",
"carrier: none (承接者:无) · noted in PR Acceptance notes, not filed · explain's purge record verdict asks the update gate though purge destroys like delete; dormant, the object gate refuses purge to every principal",
"carrier: none (承接者:无) · noted in PR Acceptance notes, not filed · checkAuthoredRowWrite's docblock says a private-OWD cross-owner by-id write is still refused end to end; true only for a caller whose read scope hides the row, admitted with org read depth (measured by the new row)"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
ACCEPT → PR #24713 comment 6281141106 ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T01:26Z. Landing waits for every check on the head to converge.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Landed ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-20T02:38Zadded_to_merge_queue2026-10-20T01:48Z →merged2026-10-20T02:37Zorigin/mainby content71a8b5eed6(one parent)..changeset/24647-explain-authored-row-write.mdis present.probeAuthoredRowWritehas 3 hits inexplain-engine.tsand 5 insecurity-plugin.ts; the control,explainAccessForAskerin the same engine file, has 3 hitsState change in this stroke:
pm:dispatchedoff and the assignee cleared (the card closedcompletedon the merge). #24646 (ruling B), which waited on this card, is released in the same round.Generated by Claude Code