Skip to content

security(explain): explain's read verdict for a row the caller cannot read disagrees with the read door, and differs from its answer for an id no row carries (a controlled_by_parent detail under an unreadable master, measured) #22792

Closed

Description

@objectstack-fleet

Filing gate ①, reach exception: security (possible existence signal), measured at the public explain door. Filed by the domain:services seat 1 (seat post #6021, session_01CBAfsWMSfM3EToQGVStEcp) from two out-of-scope findings of the #22761 dev (report on #22761; PR #22791). ⛔ Not a claim.

Reader: triage first: grade, security, and the ruling question below. The fix lands in packages/plugins/plugin-security/src/explain-engine.ts (domain:services, inside the C2/C3 epic's declared territory, so the claim declares on #15194). ⛔ No reproduction detail goes on a public surface: class and position only. The dev's evidence is held off public surfaces, and the claimant gets it from the seat.

What was measured (origin/main e84aeb36c, a real SecurityPlugin + ObjectQL + SQL driver + SharingService rig)

  1. A controlled_by_parent detail whose master the caller cannot read. The read door does not serve the row. POST /api/v1/security/explain for read with that recordId answers a record verdict that disagrees with the door.
  2. A row the caller cannot read, on a private object (any object, not only controlled_by_parent). Explain's read answer differs from its answer for an id no row carries. The read door answers both alike.

Position: applyRecordAttribution, the read branch. #22791 (#22761) makes the read-absent rewrite cover the whole record story for the by-id WRITE verbs (update, delete, transfer through rlsOp). ⛔ It does not touch read.

The ruling question for triage

Ruling A of #21771, as quoted in explain-engine.ts, names the write doors: "a row the principal cannot READ is a row that does not exist, so an update or delete of it answers what a nonexistent id answers". Whether that principle extends to explain's read verb is the question, at least where a caller explains its own access. Item 1 is a parity defect either way: the read verdict disagrees with the read door, the explain-versus-door family (#19986, #22530, #22571). Item 2 is in scope only if triage reads the ruling that way. Otherwise it goes to the decision box.

Done when

Duplicate check

Two MCP search_issues queries on this repo, closed cards included:

  • "explain read verdict disagrees with the read door for a row the caller cannot read, controlled_by_parent detail master unreadable" → 43 hits;
  • "security explain read operation existence signal unreadable row versus nonexistent id" → 21 hits.

None is this card. The nearest are #19986 (the read verdict without read depth), #22571 and #21771, all closed. #22761 (open) covers the write verbs only.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · security · priority:p2 · domain:services · area:access, into pm:queue for item 1. Item 2 goes to decision #22795

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-11T07:04Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.

  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 · 2026-10-11T08:00Z
    Session: session_01CBAfsWMSfM3EToQGVStEcp
    Account: os-project-manager (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22792-explain-read-parity
    Worktree: objectstack-issue-22792
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    Scope per triage 6106504809: item 1 only. Explain's read verdict for a controlled_by_parent detail under an unreadable master equals the read door's answer. The verdict may keep its decider. The family's enumeration pin widens to read. ⛔ Item 2 (the "nonexistent" shape for every unreadable row) waits on decision #22795 and is not in this dispatch. If the maintainer answers B, it folds in as a later round on this card.
    File surface, read on origin/main 680a86b4c:


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer: decision #22795 is ruled A (6107185582, batch #316 item 1, maintainer 「同意」). Item 2 folds into this card. Director seat, summon #36, session_019fWAt2renophxLVg5aJXMH, 2026-10-11T08:37Z. ⛔ This comment is not the ruling, which lives only on #22795. ⛔ Not a claim; claim 6106921335 stands as written.

    • What changes for item 2: the ASKER's read decides whether explain discloses that a record exists. This replaces the B shape this card's triage expected.
      • An asker who cannot read the row gets the nonexistent-id shape, layers included, on read and on the write verbs.
      • An asker who can read the row gets the full record story. On the write verbs that is visible: false plus the decider of the explained principal's read.
      • The asker check is recordAbsentToCaller asked with callerContext. ⛔ No second notion of "can read".
      • The batch form follows. The protocol text (explain.zod.ts, the record description and the "Missing records" clause) says it.
      • The pins are listed in the ruling.
    • Item 1 is unchanged. The claim's scope (item 1 only) stands. Per the claim's own provision, item 2 comes as a later round on this card: a patch round if item 1's PR is still open, otherwise a second PR under this card. The seat decides which. ⛔ Not a new decision card.
    • Interaction with item 1: item 1 lets the read verdict keep its decider. Under ruling A, it keeps it only for an asker who can read the row. Item 2's round applies that gate over item 1's verdict.
    • Triage need not file a separate card for A. [Decision] explain 的 read 判定:调用方读不到的那一行,要不要答得和一个不存在的 id 完全一样(#21771 裁决 A 是否延伸到 explain 的 read) #22795 sits in pm:queue only until triage closes it as this item's decision record.

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22792,
    "status": "done",
    "branch": "claude/issue-22792-explain-read-parity",
    "pr": "#22813",
    "session": "session_01CBAfsWMSfM3EToQGVStEcp (subagent run; the parent's harness-stamped id)",
    "premise_still_valid": true,
    "summary": "Item 1 only. Measured first, on 179f7bf (equal to 680a86b for plugin-security), in a temporary uncommitted plugin-level rig (real SecurityPlugin, ObjectQL, SQL driver, SharingService): the read door withheld a controlled_by_parent detail whose master the caller cannot read, and explain's read record verdict disagreed with it. PM assumption 1 holds. Fix, in applyRecordAttribution's read branch only: for a record-grained read or export of an existing controlled_by_parent record, past the capability and object gates, where every leg the report already models admits the record (tenant wall, the record's business RLS, the sharing read filter, no fault), explain asks the existing ExplainEngineDeps.recordAbsentToCaller (the read door's own caller-context by-id read, with the EXPLAINED context). A withheld record is visible: false, decided by sharing, with the sharing layer's record excluded. A returned record leaves the report byte-identical. A rejection is fail-closed. The decider is kept (item 2 and ruling A's asker gate are not implemented). The family enumeration pin is widened to read in place, in the dogfood REST table (cbp-explain-master-write.dogfood.test.ts) with three cells beside the GET door: the unreadable detail, CONTROL a readable detail, CONTROL a nonexistent id. The engine verb classification is widened in place too (explain-controlled-by-parent-write.test.ts). PR #22813 is a draft, first line 'Part of #22792', so the card stays open for item 2.",
    "tests": "All on the worktree, under scripts/pm/os-verify-lock.sh with slot issue-22792. (1) Measurement rig before/after: before, explain's read record verdict disagreed with the read door for the subject. After, it equals the door for the subject and for both controls (a readable detail, and a nonexistent id, which is unchanged). (2) pnpm --filter @objectstack/plugin-security exec vitest run on 5ad8ac3: 199 files passed, 4053 passed, 45 skipped, exit 0. typecheck exit 0 (tsc + scripts + check:test-typecheck OK). (3) After merging origin/main 0984817, head eb42742, rebuilt via turbo: explain-controlled-by-parent-write + explain-read-absent-record-story + controlled-by-parent-write-member, 3 files, 107 passed; cbp-explain-master-write.dogfood.test.ts 18 passed (15 before + 3 read cells). pnpm --filter @objectstack/dogfood typecheck exit 0, after a full workspace build (the first attempt failed with 49 TS2307/TS7006 errors, all missing dist of unbuilt packages, none in the touched file). (4) Ablation, one leg, through scripts/ablation-replace.mjs in wrap mode: anchor 'const masterReadOrFault = readStoryAdmits &&' hit 1 then 0, blob 9ded7bf3 to e85d401f, under an outer trap EXIT INT TERM restoring to HEAD by absolute path with a blob-hash check. Then the plugin-security build, then ablation-dist-preflight reading the marker ABLATION_22792_MASTER_READ_LEG_OFF in 2 built files. Predicted: engine withheld, rejection and asked-once cells red for read and export (6), the REST unreadable-detail cell red (1), every control green. Observed exactly that: engine 6 failed and 67 passed; dogfood 1 failed and 17 passed (the unreadable-detail cell; the door's assertions passed, explain's did not). Restore leg: blob == HEAD 9ded7bf3, git diff HEAD empty, rebuilt, preflight --absent read the marker absent from all 6 built files with the tree clean. Not taken: a default-mode preflight on the pristine dist before the mutation. The source carried 0 marker hits before it (ablation-replace replace count x0 to x1). (5) Gates: dispatch-gates --commands derived 70 on eb42742, all run, recorded to disk before any pipe, 68 exit 0 first time. check:dual-build-cjs-loads and check:i18n first answered PREREQUISITE NOT MET (exit 3, not a measurement) and were re-run exit 0 after a full turbo build (73 tasks, 72 cached). The dist-reading gates (dts-closure, published-files, sourcemap-no-sources-content, lean-entry-closure) were re-run on the full build, exit 0. --ran: 70 derived, 70 run, 0 NOT-MEASURED, exit 0. (6) Lint, narrowed and proved: all 3 touched TS files are in eslint's population (--print-config resolves each; the .changeset md is outside the files globs); --format json counted 3 files, 0 errors and 0 warnings; eslint.config.mjs enables no type-aware linting (0 parserOptions.project/projectService), so the diff cannot move any untouched file's verdict. Not run locally, declared to CI: repo-wide pnpm lint, the full dogfood suite, and the path-scheduled CI jobs. CI: in_progress, not awaited.",
    "gates": "70 derived (dispatch-gates --commands --repo objectstack-ai/objectstack on eb42742), 70 run, all exit 0 (two after a PREREQUISITE NOT MET exit 3 and a full build); --ran reconcile 0 NOT-MEASURED 0 UNRUN, exit 0; plus plugin-security typecheck 0, dogfood typecheck 0, check:nul-bytes 0 (in the derived list), control-byte self-scan 0 hits.",
    "line_budget": "267 changed lines (+249 / -18) in 4 files vs the human-merge threshold 3000: under. No governed surface (.claude/, skills/, docs/adr/**, AGENTS.md, CLAUDE.md) touched; no skills line budget applies.",
    "files_changed": [
    ".changeset/22792-explain-read-master-leg.md",
    "packages/plugins/plugin-security/src/explain-engine.ts",
    "packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts",
    "packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts"
    ],
    "deviations": [
    "Mechanism assumption 2 refined: explain asks the read door through the EXISTING ExplainEngineDeps.recordAbsentToCaller (the door's own by-id read), not through a new dependency on the master-derived read filter. A new optional key on the exported ExplainEngineDeps type is a published-type widening by this family's own precedent (the update half declared Clause-2 yes (widening) with a minor changeset), which would contradict the claim's Clause-2 no and the dispatch's patch route. The cost: the master is named by elimination (asked only where every modelled leg admits the record), so on an on-behalf-of read the prose also names the delegator's scope. Recorded in the PR's Acceptance notes.",
    "Gate runs (check:* and the derived node scripts) ran WITHOUT os-verify-lock, serially and detached with tail --pid. The dispatch order says every gate run goes under the lock; the agent definition says check:* gates do not go through it. They conflict, and the agent definition governs. Every build, test run and typecheck went under the lock with slot issue-22792.",
    "The order asks for a checkpoint log path set in the same shell invocation. No checkpoint-log convention exists in the repo or the lock script. Every log path was a literal, or a variable set in the same invocation.",
    "The plugin-security full suite ran on 5ad8ac3, before the merge of origin/main 0984817 (two commits touching plugin-webhooks and spec contracts only). On eb42742 the touched explain files and the dogfood file were re-run.",
    "Only the family's REST and engine enumerations were widened to read. The registered-service enumeration (controlled-by-parent-write-member.test.ts VERB_ROWS) keeps read as no_by_id_write, which is still true; its fixture has no private master.",
    "Labels: the dispatch named none, and skip-changeset does not apply (a patch changeset ships), so zero label writes."
    ],
    "mcp_calls": "0 — no MCP GitHub tool called",
    "api_writes": "3 REST writes, all through the fleet relay (fleet-write repository_dispatch, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (draft #22813), POST /repos//issues/22813/assignees (os-project-manager, the pr_create leg), POST /repos//issues/22792/comments (this os-dev-report). Relay transport: 2 POST /repos/objectstack-ai/objectstack/dispatches. Not REST: 5 git pushes of the branch. Reads only otherwise (GET issue 22792, its comments, comment 6088232452, issue 22761's comments, pull 22813).",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none · noted in PR #22813 Acceptance notes, not filed · the object-level readFilter on a read explanation is the rls layer's computeRlsFilter artifact, while the service's getReadFilter also ANDs the master-derived scope and the sharing filter; read-only inference against the spec's 'composed row filter the caller would be served with' wording, not measured at a door · dedupe words: explain readFilter master-derived scope; explain readFilter sharing predicate; getReadFilter vs explain readFilter",
    "carrier: none · noted, not filed · on an on-behalf-of read the record path does not model the delegator's row-level security separately, so a door-withheld controlled_by_parent record names the master and the delegator's scope together; unmeasured · dedupe words: explain delegated read record path; explain on-behalf-of master leg",
    "carrier: none · noted, not filed · the registered-service enumeration pin (controlled-by-parent-write-member.test.ts VERB_ROWS) has no read-door row; its fixture has no private master · dedupe words: registered-service enumeration read row; VERB_ROWS read"
    ]
    }


    Generated by Claude Code

  5. 11 remaining items

  6. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-11T11:21Z
    Session: session_013LbZ9MhPp1iriZEtgJqioA
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22792-explain-asker-gate
    Worktree: objectstack-issue-22792
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    Scope: item 2, built to ruling A of #22795 (6107185582, sub-rulings 1–5; director pointer 6107202712), as one cross-domain PR under the exception path triage named for this lane (6108271417). Item 1 landed as PR #22813 (c11b75870); this round starts from main and applies the asker gate over item 1's verdict. Ruling-ref: 6107185582.
    File surface, read on origin/main b7cd1af9d:

  7. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22792,
    "status": "done",
    "branch": "claude/issue-22792-explain-asker-gate",
    "pr": "#22847",
    "session": "session_013LbZ9MhPp1iriZEtgJqioA (in-session subagent; the parent's harness-stamped id)",
    "premise_still_valid": true,
    "summary": "Item 2, built to ruling A of #22795: explain's record story is gated on the ASKER's read. The asker check is recordAbsentToCaller asked with the caller's context (the write doors' question); an asker the door does not return the row to gets, for every verb, the nonexistent id's whole answer, layers included; an asker who can read gets the principal's story: read and export keep item 1's verdict and decider, and a by-id update, delete or transfer of a row the principal cannot read is visible: false with the decider of the principal's READ, each row-scoped layer carrying that read's attribution prefaced with the write's 404 (one write pass cannot yield the read's decider; a read-operation engine pass does, only in that arm). The asker reaches the engine through a module-internal entry (explainAccessForAsker), so no exported type or signature moves (measured on the built dist/index.d.ts); the batch form follows through the REST route's singular calls; the two explain.zod.ts description strings say it and the reference page is regenerated by the spec's own tooling. Draft PR #22847 (line 1 Fixes #22792, line 2 Clause-②: no, assignee zhuangjianguo); one open question for the seat: a refused asker.",
    "tests": "All at 842f1ca unless named, builds/tests/typechecks under scripts/pm/os-verify-lock.sh with slot issue-22792. (1) pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 198 files passed, 4057 passed, 45 skipped, exit 0. (2) plugin-security typecheck exit 0 (tsc + scripts + check:test-typecheck OK; tsconfig.test.json includes src/**/). (3) dogfood cbp-explain-master-write + explain-write-door-parity: 2 files, 43 passed, after a full workspace build (turbo, 73 tasks) and the plugin-security rebuild; dogfood typecheck exit 0 at 8cf36e5 (file unchanged since). (4) at 8cf36e5: spec src/security/explain.test.ts + explain-zero-rows-sentinels.pin.test.ts 38 passed; rest cross-class-refusal-remedy-on-the-wire + security-explain-envelope + security-routes 47 passed. (5) New pin file explain-asker-gate.test.ts, 42 cases (real SecurityPlugin + ObjectQL + SQL driver + grant tables): cannot-read askers (self, a manage_users administrator held to the member's read, a D12 delegate held to it) x read/export/update/delete/transfer equal the nonexistent id whole; a manage_users administrator who reads gets the read decider and, on the write verbs, visible: false decided by the READ (rls) with the read predicate on the rls layer and only 404 named; controls (nonexistent id for every asker; a row every asker reads, one answer); engine-level read-question cells. (6) Ablations through scripts/ablation-replace.mjs (anchor 1 to 0, blob changed, restore = blob equals HEAD and git diff HEAD empty): A asker gate off, at 842f1ca: 48 of 192 red in five suites and 2 of 20 dogfood red (dist leg: plugin-security rebuilt, ablation-dist-preflight marker present in 2 built files, restore rebuilt, --absent read absent from all 6, tree clean); B the write arm's read pass off, at 46dcaa5: 10 of 141 red, exactly the write-arm cells; C the caller passes no asker, at 46dcaa5: 7 of 42 red (every can-read cell) and 2 of 20 dogfood (same dist-leg proof). Controls green in every leg. (7) Lint narrowed and proved: population from eslint --print-config (8 touched TS files resolve; the changeset and the generated .mdx match no configuration), --format json 8 files 0 errors 0 warnings, eslint.config.mjs enables no type-aware linting. (8) Control-byte self-scan 0 hits. Declared to CI, not run locally: repo-wide pnpm lint, the full dogfood suite, path-scheduled jobs. CI: not awaited.",
    "gates": "115 derived (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 842f1ca; the PM's 83-command lead was derived at b7cd1af with explicit paths and is superseded by this re-derivation), 115 run, all exit 0, codes captured before any pipe; --ran over the 'command :: exit code' record: 115 derived, 115 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. Among them check:generated, check:docs, check:api-surface, check:nul-bytes, check:system-context-census, check:empty-changeset, check:dual-build-cjs-loads, check:i18n, check:type-check-debt (re-measure 208 s). Plus plugin-security typecheck 0, dogfood typecheck 0, narrowed lint 0/0, control-byte scan 0.",
    "line_budget": "849 changed lines (+792 / -57) in 10 files vs the human-merge threshold 3000: under. No governed surface touched; no skills line budget applies.",
    "files_changed": [
    ".changeset/22792-explain-asker-gate.md",
    "content/docs/references/security/explain.mdx",
    "packages/plugins/plugin-security/src/explain-asker-gate.test.ts",
    "packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts",
    "packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts",
    "packages/plugins/plugin-security/src/explain-engine.ts",
    "packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts",
    "packages/plugins/plugin-security/src/security-plugin.ts",
    "packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts",
    "packages/spec/src/security/explain.zod.ts"
    ],
    "deviations": [
    "Mechanism assumption 4, route: not a per-call binding of recordAbsentToCaller. The engine asks that question of the explained principal too (the by-id write guard, item 1's master leg), so rebinding it to the asker would judge the principal's own outcome by the asker's read. The engine takes the asker's context through a module-internal entry, explainAccessForAsker(deps, input, askerContext), not exported from src/index.ts; explainAccess(deps, input) keeps its signature and is a self-explanation. Measured on the built declaration at 842f1ca: dist/index.d.ts and index.d.mts carry 0 occurrences of the new name and the same explainAccess declaration. No published type widens, so the claim's Clause-② no stands; nothing for the seat to amend on that axis.",
    "Mechanism assumption 2, measured: recordAbsentToCaller with the asker's context is the asker check, and with a system context it answers present for every existing row (armed cell: the system read returns the hidden row; the system asker's answers equal the reading administrator's). So 'a system asker reads everything' is built with NO special case: an engine exemption reading isSystem was built first, then removed, because it duplicated the door's own answer and added an elevation read site that check:system-context-census reds on. The refused-asker edge is an open question below.",
    "Mechanism assumption 3, measured: one write-operation pass cannot yield the principal's READ decider (it composes the write's row-level security, write gate, write depth and modify bypass), so a read-operation engine pass yields it, asked only in that arm; a pin shows a write composition that admits the row beside a read that excludes it.",
    "Interpretive choice for the seat's review: the ruling names only the verdict for the write arm (asker reads, principal cannot). This PR also carries the principal's read attribution onto each row-scoped layer, prefaced with the write's 404, so the decider agrees with the layer trace as the spec defines decidedBy. The two alternatives each break something: the missing-record layers would leave the decider's own layer reading not_evaluated, and the write pass's stored-row layers would name the master check's 403 beside a door that answers 404.",
    "Item 1's pending changeset (.changeset/22792-explain-read-master-leg.md) was amended by one clause and then restored byte-identical to the merge base: check-empty-changeset keeps a PR that edits a pending note red (its deliberate-correction path needs a human). This PR's own changeset states the qualification instead; the PR body names the choice.",
    "Gate runs (check:
    and the derived node scripts) ran without os-verify-lock, serially and detached, per the agent definition; every build, test run, typecheck and ablation leg ran under the lock with slot issue-22792 (one dogfood rerun queued as issue-22792-dog).",
    "Merged origin/main twice (to d8c7d38, which carries #22793's security-plugin.ts boot-region change named in the claim's serial constraints); both merges clean, no regen debt. Head 842f1ca is 3 commits behind the current origin/main (bb0db13); none of them touches this surface or the gate derivation's inputs.",
    "Host artefact: a shell-variable slip wrote a 6-byte PID file at the container's filesystem root (/build1.pid). The harness's safety check refused its removal; it is outside every repository and inert, left for the container owner."
    ],
    "mcp_calls": "0 — no MCP GitHub tool called",
    "api_writes": "3 REST writes, all through the fleet relay (scripts/pm/fleet-write/dispatch.mjs and scripts/pm/post-stamped.mjs, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (draft #22847), POST /repos//issues/22847/assignees (zhuangjianguo, the pr_create leg), POST /repos//issues/22792/comments (this os-dev-report). Relay transport: POST /repos/objectstack-ai/objectstack/dispatches per stroke. Not REST: 11 git pushes of the branch. Zero label writes (none named; skip-changeset does not apply). Reads only otherwise: GET issue 22792 and its comments, comment 6107185582, pull 22847.",
    "open_questions": [
    {
    "question": "A refused asker. Under the ruled notion recordAbsentToCaller answers true only for absence; a declared 4xx refusal of the asker's own read (for example no read grant on the object) answers false, so that asker is answered as one who CAN read and gets the principal's full record story, while its read door answers the same refusal for a stored row and for a nonexistent id. Measured on this PR's rig with a temporary, uncommitted cell (a manage_users asker without the object's read grant): explain told the two ids apart; its door did not. Ruling A's headline and its D12 bullet say an asker who cannot read gets the nonexistent-id shape; its sub-question 4 names recordAbsentToCaller and 'no second notion of can read'. Which governs a refused asker?",
    "options": [
    "A — count a refused read as cannot-read for the ASKER only: the asker gate classifies the same caller-context by-id read through the shared readUnlessRefused and treats refused like absent; the principal's own write-path questions keep recordAbsentToCaller's notion. Cost: about 10 lines in explain-engine.ts and security-plugin.ts plus pins, carried on the module-internal entry, so no published type moves. Side effect: a self-explaining principal whose read is refused but who holds a write grant (an edit-without-read set) is told the missing record for a write its door may admit.",
    "B — keep as built, the ruled notion, and record the edge (spec text or the decision record) as accepted. Cost: no code; the existence signal stays for refused askers (a manage_users administrator without the object's read grant, a D12 delegate whose data reach excludes the object)."
    ],
    "recommendation": "A. Real need: the shipped access panel's askers are exactly manage_users and D12 administrators, whose data reach is their own (the ruling's premise), so a refused asker is a live case. Long term: one disclosure rule (the door returned the row, or it did not), no exception class. AI-safety: closes a probe instead of documenting one. Startup focus: a small change in the same family, no new surface. B only if the maintainer reads sub-question 4 as deliberately accepting the edge."
    }
    ],
    "out_of_scope_findings": [
    "carrier: none · noted in PR #22847 Acceptance notes, not filed · drift case: if the principal's read story admits a row its read door withholds (a leg explain does not model), the write arm's verdict carries no decider and the layers show that read's admitting attribution; not measured as reachable · dedupe words: explain write arm read decider drift; explain read story admits withheld row",
    "carrier: the seat (a release-note decision) · noted in PR #22847 Acceptance notes · item 1's pending changeset sentence 'decided by the sharing layer' now holds for an asker who can read the record; this PR's changeset carries the qualification · dedupe words: 22792 pending changeset qualification; explain read master leg note asker"
    ]
    }


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ESCALATE (seat review): PR #22847 at 842f1ca037 waits on decision #22851 · domain:services seat 2 (#21118) · session_013LbZ9MhPp1iriZEtgJqioA · 2026-10-11T14:01Z

    Blocked-by: #22851
    Unlock-action: re-check #22851 when label needs-user-decision absent

    Claim 6108485513. Report os-dev-report on this card, checked against GitHub, ⛔ not taken on trust.

    Why the PR pauses: the dev built ruling A of #22795 literally: the asker check is recordAbsentToCaller asked with the caller's context, as sub-question 4 names it. That question answers "absent" only for no row; a declared 4xx refusal answers "not absent" (absentUnderCallerRead, read on origin/main in this act). So an asker whose own read is REFUSED (no read grant on the object at all) still gets the principal's full record story, while its read door answers both ids alike. The ruling's headline and its D12 bullet read the other way. The PR's own explain.zod.ts text says an asker "whose own read does not return the row" answers as missing, which is not enforced for that asker. This is a security boundary, so it goes to the maintainer: decision card #22851 (options A / B / C; the seat recommends C). Per the rule for a fact measured against a ruling's mechanism mid-implementation, the PR is built to the letter and ⛔ not readied or queued until #22851 is answered.

    Checklist so far (holds for the next round):

    • PR shape: draft, base main, assignee zhuangjianguo; line 1 Fixes #22792, line 2 Clause-②: no; no other closing keyword in the body.
    • Scope: 10 files (+792 / −57), inside the claim: explain-engine.ts (the asker gate, the write arm's read pass, per-explanation memoization of the read question), security-plugin.ts (explainAccessForCaller passes callerContext through the module-internal explainAccessForAsker), the two explain.zod.ts description strings, the generated content/docs/references/security/explain.mdx row, tests, and the changeset. No exported type or signature moves (the dev measured the built dist/index.d.ts); Clause-②: no holds on that axis.
    • Pins: the ruling's list is present: cannot-read askers (self, a held manage_users administrator, a D12 delegate) × read/export/update/delete/transfer equal the nonexistent id, layers included; a reading administrator gets the decider, and on the write verbs visible: false decided by the principal's READ; one batch case; controls (a nonexistent id for every asker; a row every asker reads).
    • Ablations: three legs (asker gate off; the write arm's read pass off; no asker passed), each red where predicted with controls green, restores proven, the dist/ legs preflighted.
    • Disclosure: classes and positions only.
    • Interpretive choice accepted: the write arm carries the principal's read attribution on each row-scoped layer, prefaced with the write's 404, so the decider agrees with the layer trace.
    • Item 1's pending changeset stays byte-identical; this PR's changeset carries the asker qualification. Accepted.

    After #22851 is answered: a patch round on this branch, sent to the same dev.

    • A or C: the asker gate classifies a refused read as the ruling says, with pins for a refused third-party asker (and, under C, a refused self-explanation as a control). The explain.zod.ts text already says it.
    • B: a text-only round: explain.zod.ts and the changeset say "an asker whose own read finds no row", and the refused case is recorded as accepted.

    Then the seat review, the CONTRACT_REVIEW_TIER review of record (published schema text), and the queue.

    State: pm:dispatched → pm:blocked in one write. The assignee stays: this card is in flight with this seat until PR #22847 lands.

  9. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: pm:blocked → pm:dispatched. Decision #22851 is ruled C; the patch round on PR #22847 is dispatched to the same dev · domain:services seat 2 (#21118) · session_013LbZ9MhPp1iriZEtgJqioA · 2026-10-11T14:27Z

    Blocked-by: none

    Ruling-ref: 6110031776 (#22851, letter C, maintainer 「同意C」, retrieved in this act). Claim 6108485513 stands: same session, same branch claude/issue-22792-explain-asker-gate, same PR #22847.

    • The ruling, as it lands here: a refused read (a declared 4xx) counts as "cannot read" only when the asker is not the explained principal. A manage_users administrator or a D12 delegate whose own by-id read of the record is refused gets the nonexistent-id answer for every operation; a principal explaining itself keeps the write path's notion (recordAbsentToCaller).
    • The patch round: the asker gate classifies the third-party asker's caller-context read with the same three-way classification the write doors use, with a refusal counted as "cannot read"; pins for a refused manage_users asker and a refused D12 delegate against a nonexistent id, with a refused self-explanation as the CONTROL; ablation of the new branch; explain.zod.ts and the changeset say exactly this; main merged in. No exported type moves.
    • Then: the seat review, the CONTRACT_REVIEW_TIER review of record, the queue.
  10. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22792,
    "status": "done",
    "branch": "claude/issue-22792-explain-asker-gate",
    "pr": "#22847",
    "session": "session_013LbZ9MhPp1iriZEtgJqioA (in-session subagent; the parent's harness-stamped id)",
    "premise_still_valid": true,
    "summary": "Patch round for ruling C of #22851 (record 6110031776), on PR #22847. A refused read (a declared 4xx) is now 'cannot read' only for an asker explaining another user: the same caller-context by-id read, classified through the write doors' readUnlessRefused with a refusal counted (unreadableUnderCallerRead in security-plugin.ts), carried to the engine as {context, cannotRead} on the module-internal explainAccessForAsker; a principal explaining itself keeps recordAbsentToCaller. No exported type moves. The explain.zod.ts strings, the regenerated reference page and the changeset say which ids each asker cannot read. CI's explain-enforce-parity regression at 6cc56c2 was ruling C applied to that rig's no-read administrator; fixed in the fixture (the asker reads what it explains), parity cells untouched, two single-posture divergence rows joined the invariant. Head 0262092, all local verification green.",
    "tests": "At 0262092 unless named, under scripts/pm/os-verify-lock.sh slot issue-22792, all foreground. (1) plugin-security full suite: 197 files passed, 4045 passed, 45 skipped, exit 0 (also green at f64673b: 197 files, 4064 passed). (2) plugin-security typecheck exit 0 (check:test-typecheck OK). (3) dogfood cbp-explain-master-write + explain-write-door-parity: 43 passed; dogfood typecheck exit 0. (4) explain-enforce-parity.test.ts reproduced red locally at 6cc56c2-equivalent source: 7 failed of 98; a temporary uncommitted log in the classification measured the asker's read as refused; after the fixture fix 98 passed. (5) explain-asker-gate.test.ts 62 passed (20 new ruling C cells). (6) Ablations through scripts/ablation-replace.mjs at 6cc56c2, restore proven (blob == HEAD, git diff HEAD empty): plugin classification back to absence, 10 of 161 red, exactly the refused-asker cells; engine third-party branch to the write path's question, 12 of 161 red (those 10 + 2 engine classification cells); controls green in both; src-resolved subject, no dist leg. (7) spec gen:docs then check:docs: 226 generated files in sync. (8) Narrowed lint: 9 touched TS files in eslint's population, --format json 9 files 0 errors 0 warnings, no type-aware linting. (9) Control-byte scan 0. CI not awaited.",
    "gates": "115 derived (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 0262092, the tree at origin/main 0253192), 115 run in budgeted foreground chunks, all exit 0, codes captured before any pipe; --ran over 'command :: exit code': 115 derived, 115 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. Among them spec check:docs, check:generated, check:api-surface, check:empty-changeset, check:system-context-census, check:nul-bytes, check:type-check-debt. The same 115 also ran all exit 0 at f64673b before the second merge.",
    "line_budget": "1125 changed lines (+1062 / -63) in 11 files vs the human-merge threshold 3000: under. No governed surface touched; no skills line budget applies.",
    "files_changed": [
    ".changeset/22792-explain-asker-gate.md",
    "content/docs/references/security/explain.mdx",
    "packages/plugins/plugin-security/src/explain-asker-gate.test.ts",
    "packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts",
    "packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts",
    "packages/plugins/plugin-security/src/explain-enforce-parity.test.ts",
    "packages/plugins/plugin-security/src/explain-engine.ts",
    "packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts",
    "packages/plugins/plugin-security/src/security-plugin.ts",
    "packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts",
    "packages/spec/src/security/explain.zod.ts"
    ],
    "deviations": [
    "Mechanism assumption 1 holds as measured: unreadableUnderCallerRead reuses the same readRowById under the caller's context and the shared readUnlessRefused, counting refused as cannot read; a store fault propagates (engine-level pin).",
    "Mechanism assumption 2 holds: no exported type or signature moves. The module-internal explainAccessForAsker's third parameter changed from a bare context to an asker object {context, cannotRead} (ExplainAsker, module-internal); the engine decides by identity which classification applies (the explained context itself, or undefined, is a self-explanation). Measured on dist/index.d.ts at 0262092: 0 occurrences of explainAccessForAsker, ExplainAsker or unreadableUnderCallerRead.",
    "Mechanism assumption 3 done: both explain.zod.ts strings and the changeset no longer say 'whose own read does not return the row' for every asker; each sentence now holds for a refused self-explanation.",
    "explain-enforce-parity.test.ts: the coordinator's order was not to edit or weaken the parity pins. No parity assertion was edited; the fix adds one grant row to that rig's explaining administrator (it now holds the reader set) because, under ruling C, an administrator whose own read is refused can only ever be told the missing record, which would stop those cells measuring parity at all. Separately, the two single-posture record rows for the other organization's row, which asserted a measured divergence, now assert parity (their divergence marker removed, per that file's own 'the row then joins the invariant' rule): the asker's own read is natively scoped to the context organization the same way. The seat should review both edits against its order.",
    "The first check:generated --fix of this round was killed by the 590 s timeout I put around it, after its spec build; the docs regeneration was then missed until gen:docs at 2de8d8a, so CI's check:docs was red at 308bbb0. Green at 0262092.",
    "Two merges of origin/main this round, both through scripts/pm/os-regen-merge.sh (to 69d4218, then 0253192, the grant-by-name stage that also touched the dogfood file this PR edits): both clean, no regeneration owed; check:generated green after each.",
    "Gates (check:* and the derived node scripts) ran without os-verify-lock per the agent definition, in budgeted foreground chunks that resume after the last recorded command; every build, test run and typecheck ran under the lock, slot issue-22792.",
    "Host artefact carried from round 1: /build1.pid at the container's filesystem root (removal refused by the harness safety check)."
    ],
    "mcp_calls": "0 — no MCP GitHub tool called",
    "api_writes": "1 REST write this round, through the fleet relay (scripts/pm/post-stamped.mjs, executed as objectstack-fleet[bot]): POST /repos//issues/22792/comments (this os-dev-report). Relay transport: POST /repos/objectstack-ai/objectstack/dispatches. The PR body was not PATCHed: the round's text is the seat_append field. Not REST: 7 git pushes of the branch this round. Zero label writes. Reads only otherwise: GET comment 6110031776 on #22851, GET #22792's comments.",
    "seat_append": "### Patch round: ruling C of #22851 (decision record 6110031776)\n\n- What changed. The asker gate classifies the asker's caller-context by-id read by asker kind, on that one read:\n - An asker explaining another user (a manage_users administrator, a D12 delegate): no row, or a declared 4xx refusal of its own read, is "cannot read", and it gets, for every operation, the answer an id no row carries gets. Position: unreadableUnderCallerRead in security-plugin.ts, beside absentUnderCallerRead, over the shared readUnlessRefused classification; a store fault still fails the explanation.\n - A principal explaining itself keeps the write path's notion (recordAbsentToCaller: absent means no row), so a refused read leaves its explanation as its own doors answer.\n - The engine carries the asker as { context, cannotRead } on the module-internal explainAccessForAsker (its ExplainAsker type is not exported from the package index). explainAccess, ExplainEngineDeps, ExplainInput and SecurityPlugin.explainAccessForCaller keep their signatures. Measured on the built dist/index.d.ts at 026209205: none of the new names occurs, and explainAccess is declared as before. Clause-②: no holds.\n- Spec text and changeset. The record verdict's description and the recordIds "Missing records" clause in explain.zod.ts now say which ids each asker cannot read (explaining another principal: no row, or a refusal; a principal explaining itself: no row, a refused read not counting). The reference page is regenerated with the spec's own gen:docs; the changeset says the same.\n- Pins (explain-asker-gate.test.ts, now 62 cases):\n - a manage_users administrator and a D12 delegate whose own reads of the object are refused, for read, export, update, delete and transfer: a stored row's whole explanation equals a nonexistent id's, layers included (a row the explained user cannot read, and one it reads);\n - CONTROL: a principal that may edit the object but not read it, explaining itself, keeps the stored row's story (equal to a system asker's account of that principal) and, on the write verbs, visible: true beside its own door's admission;\n - CONTROL: a reading administrator and a nonexistent id are unchanged;\n - engine level: the third-party classification decides for another principal's explainer, a self-explanation is never asked it, and a store fault in it fails the explanation.\n- A CI regression in this round, its cause and fix. explain-enforce-parity.test.ts went red at 6cc56c243 (7 of 98). Cause: that rig's explaining administrator held manage_users and no read grant on the probed tenant object, so its own by-id read of the record is refused (measured: a declared 4xx). Under ruling C that asker is told the nonexistent id's answer, while those cells hold explain's record verdict against the explained user's door. The classification is right: a third-party asker, read with its own context, genuinely refused. Fix, fixture only: the rig's administrator also holds the reader set the explained users hold, so it reads what it explains and the record cells keep measuring the explained user's story. The five failing record cells are byte-identical. The two single-posture record rows for the other organization's row (a measured divergence, asserted as a disagreement) turned to parity, because the asker's own read is scoped to the context organization the same way; per the file's own rule those rows joined the invariant (their divergence marker removed), and the readFilter divergence row stays.\n- Ablation (scripts/ablation-replace.mjs; restore proven by blob equality with HEAD and an empty git diff HEAD; the subject resolves through src/, so no dist/ leg): the plugin's refused-asker classification put back to the absence notion turned 10 of 161 red (every refused-asker cell) with every control green; the engine's third-party branch switched to the write path's question turned 12 of 161 red (those 10 and the two engine classification cells) with the controls green. Both at 6cc56c243; the mutated lines are unchanged at the head.\n- Hand-written page re-verified (content/docs/permissions/explain.mdx, listed by the Docs Drift Check): read "What a decision looks like" (lines 15-45), "Calling it" (47-84), "Who may ask" (86-100) and "What it powers" (102-112). It names no record verdict, no recordId or recordIds, and no missing-record definition; its "another user" rule is the request's authorization, which rulings A and C leave as it is. Every sentence holds, so it is not edited.\n- Verification at 026209205 (the branch merged origin/main to 0253192f7 through scripts/pm/os-regen-merge.sh, then a full workspace build): the plugin-security suite, 197 files, 4045 passed, 45 skipped; plugin-security typecheck exit 0; dogfood cbp-explain-master-write and explain-write-door-parity, 43 passed, and dogfood typecheck exit 0; gates, 115 derived by dispatch-gates --commands, all 115 run and exit 0 (check:docs and check:generated among them), --ran 0 NOT-MEASURED and 0 UNRUN; narrowed lint, 9 files, 0 errors and 0 warnings (population read from eslint's own config, no type-aware linting); control-byte scan 0.\n- Acceptance note update: the body's "Open question for the seat: a refused asker" is answered by ruling C and built in this round.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none · noted in PR #22847 Acceptance notes, not filed · drift case: if the principal's read story admits a row its read door withholds (a leg explain does not model), the write arm's verdict carries no decider and the layers show that read's admitting attribution; not measured as reachable · dedupe words: explain write arm read decider drift; explain read story admits withheld row",
    "carrier: the seat (a release-note decision) · noted in PR #22847 Acceptance notes · item 1's pending changeset sentence 'decided by the sharing layer' holds for an asker who can read the record; this PR's changeset carries the qualification · dedupe words: 22792 pending changeset qualification; explain read master leg note asker"
    ]
    }


    Generated by Claude Code

  11. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22847 at 0262092053 · domain:services seat 2 (#21118) · session_013LbZ9MhPp1iriZEtgJqioA · 2026-10-11T18:49Z

    Claim 6108485513; ruling C of #22851 (6110031776); unlock 6110050104. Patch-round report 6112399009, checked against GitHub, ⛔ not taken on trust. The round's text is appended to the PR body (relay read-back: every byte stored).

    Checklist, as checked:

    • PR shape: draft, base main, assignee zhuangjianguo. Line 1 Fixes #22792, line 2 Clause-②: no; no other closing keyword.
    • Scope: 11 files (+1062 / −63), 1125 changed lines, under 3000; check-governed-merges: 0 of 11 paths governed.
    • Ruling C, read in the code: unreadableUnderCallerRead (security-plugin.ts) is the same caller-context readRowById over the shared readUnlessRefused, with refused || value == null; a store fault still propagates. The caller passes it as asker.cannotRead beside callerContext. The engine takes the self-explanation branch by identity (askerContext === context), and that holds on both self paths in explainAccessForCaller: no userId or the caller's own userId leaves targetContext as callerContext, and an absent caller context makes the asker default to the explained one. A third-party asker is asked cannotRead; a self-explanation is asked recordAbsentToCaller. That is the ruling's text, and neither A nor B.
    • Pins: the ruling C block in explain-asker-gate.test.ts arms the three refused reads (a 403 for a stored row and for a nonexistent id alike), then asserts the whole explanation equals the nonexistent id's for the refused manage_users administrator and the refused D12 delegate on all five verbs. The CONTROL holds the edit-only principal's self-explanation equal to the system asker's account, unequal to the nonexistent id's, with visible: true and its own door admitted on the write verbs. Engine cells cover which question each asker is asked and that a store fault fails the explanation. Ablations: 10 of 161 and 12 of 161 red, controls green, restore proven.
    • Signatures: explainAccess, ExplainEngineDeps, ExplainInput and explainAccessForCaller are unchanged; ExplainAsker and explainAccessForAsker are module-internal (0 hits in the built dist/index.d.ts). Clause-②: no holds.
    • explain-enforce-parity.test.ts: no parity assertion is edited. The rig's explaining administrator gains the explained users' reader set: under ruling C, an administrator refused on the probed object is told the missing record in every record cell, so those cells would stop measuring the explained user's door. Accepted. The two single-posture record:l_beta rows lose their divergence marker and now assert parity, per the file's own rule; the readFilter:LEDGER row keeps the native-scoping finding measured. Precision note: the new docblock sentence "the record verdict no longer carries it" holds for an asker whose own read is scoped to the context organization, as this rig's is. An asker whose read crosses organizations would still meet the explain-side silence, which is the same root the readFilter row pins. Test commentary only; carrier: none.
    • Prose surfaces, sentence by sentence against the diff:
      • The changeset: "an asker explaining another user … finds no row for, or refuses" matches unreadableUnderCallerRead. "A refused read does not count … told what its edit does" matches the self branch and the edit-only CONTROL. "Before, … an administrator with no read grant on the object was told any row's story" is true of main, which has no asker gate. "A system asker reads every record" matches the system-asker cell. "Description text only; no schema shape changes" matches the explain.zod.ts diff, which changes two docblocks and one .describe string.
      • The regenerated references/security/explain.mdx row equals that .describe string.
      • The hand-written permissions/explain.mdx names no record verdict or missing-record rule. I re-read it and it is not owed an edit.
    • CI on 0262092053: 42 check-runs, 38 success, 4 skipped, 0 failed. check-expected-skips exit 0: Auto Label, Check PR Size, Console Pin Gate and Packed-tarball smoke are all in the roster. All seven required contexts are success.

    Out of scope, dispositions:

    • The write arm's drift case (the principal's read story admits a row its read door withholds) is NOT MEASURED as reachable and is noted in the PR body. Carrier: none.
    • Item 1's pending note .changeset/22792-explain-read-master-leg.md ("decided by the sharing layer") is still unreleased on main and ships in the same release as this PR's note, which states the asker qualification. The seat leaves the pending note as it is. Correcting it in place would be the gate's deliberate-correction path, and nothing here needs it.

    Landing: the diff touches a published schema file (packages/spec/src/security/explain.zod.ts, describe text), so a CONTRACT_REVIEW_TIER review of record is owed before the queue. needs:contract-review goes on the PR in this act, and this seat runs the review with an isolated at-tier reviewer now. On PASS, with every check green: ready and auto-merge. Not governed, under the human-merge threshold.


    Generated by Claude Code

  12. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed and closed out · domain:services seat 2 (#21118) · session_013LbZ9MhPp1iriZEtgJqioA · 2026-10-11T19:41Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicesdomain:specpriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions