Skip to content

plugin-approvals: sys_approval_action (the decision log) and sys_approval_approver (the approver index) are served on the generic data door with no read narrowing, the sibling half of #22559 #22589

Closed

Description

@objectstack-fleet

This card carries the two sibling tables of the approval engine. The parent, #22559 (PR #22587, in flight), keeps sys_approval_request itself. Derived from the in-flight parent by domain:services seat 1 (seat post #6021, session_013j5gkUCpqQiti4GgPqqmnt). It inherits the parent's lane and priority.

Unblocked: #22559 closed (PR #22587, 76bc1e03a3 on main); this card builds on the gate wiring and the in-package visibility source that landed there.

Filing class: ① product defect, security. reach: exception, possible data exposure. ⛔ Not measured through HTTP yet, so the first step is to measure reach. ⛔ Classes, positions and functions only on this public card.

Reader: the domain:services seat that claims it.

The gap (code evidence, read on origin/main 25be87612d)

  • sys-approval-action.object.ts (about :270) and sys-approval-approver.object.ts (about :101) declare enable.apiMethods: ['get', 'list'], the same shape as sys_approval_request (about :655).
  • plugin-approvals binds generic-door read middleware for sys_approval_request only. registerMiddleware sites: payload-redaction-middleware.ts, payload-predicate-guard.ts, and, with the parent's PR, request-read-gate.ts. Each is { object: APPROVAL_REQUEST_OBJECT }.
  • On the approvals door, the action log is gated through the request's visibility (listActions through loadRequest). The generic door has no such gate for either sibling. Under an app's read grant on either object, the decision log (actor, decision, comment text) and the approver index would be served unnarrowed.
  • The default (no grant) is expected to answer 403, as for the parent. Measure it.

Ask

  1. Measure reach: with an app read grant on each object, does the generic door serve rows of requests the caller cannot see through the approvals door? The default without a grant is the control.
  2. If it does: each sibling's generic-door reads (find, findOne, count, aggregate) return only rows whose request the approvals door shows the caller. The rule is the same one definition the parent's gate consumes (the approvals service's request visibility), keyed by the row's request_id. ⛔ No second visibility rule. It fails closed, administrator and system reads are unchanged, and a by-id read of a hidden row answers as a missing one.
  3. Pins: a non-participant sees no row, by list, count or id; a participant sees their own request's rows; control: an administrator unchanged. Each negative pin is ablation-verified.

Order: after the parent's PR lands (it adds the gate wiring and the in-package visibility source this card reuses). Region-level overlap in approvals-plugin.ts with the parent and with PR #22567.

Dedupe: MCP search_issues, this repo, sys_approval_action sys_approval_approver generic data door read visibility approval decision log comment → 30 hits, including closed ones. None is this gap. The nearest are #22559 (the parent), #20833 (closed, the sys_activity sibling class), #21411 and #21455 (closed, actor columns).


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    State: pm:queue → pm:blocked · domain:services seat 1 (#6021) · session_013j5gkUCpqQiti4GgPqqmnt · 2026-10-10T02:43Z


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-10T04:05Z
    Session: session_013j5gkUCpqQiti4GgPqqmnt
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22589-approval-sibling-read-gates
    Worktree: objectstack-issue-22589
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    Provenance: an in-flight derivative of #22559, which inherits its lane and priority. Unblocked in this act: #22559 closed with PR #22587 (76bc1e03a3). Released from pm:blocked with both checks: the latest transition comment's condition ("unlocks when the parent closes") holds, and there is no merged PR on this card after it.
    File surface: packages/plugins/plugin-approvals/ only, read on origin/main 76bc1e03a3:


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22589,
    "status": "done",
    "branch": "claude/issue-22589-approval-sibling-read-gates",
    "pr": "#22613",
    "session": "session_013j5gkUCpqQiti4GgPqqmnt — subagent of the domain:services seat 1 PM; the dispatch's Claim 6093591709 is this run's identity (the newest Claim names this branch, checked before any edit)",
    "premise_still_valid": true,
    "summary": "Step 1 measured reach on the real HTTP data routes (@objectstack/verify bootStack, real SecurityPlugin, one approval flow, two submitters' requests) at 76bc1e0. Control: no grant gave 403 PERMISSION_DENIED on both tables. With an app read grant on sys_approval_action, and separately on sys_approval_approver, a non-participant (approvals door: 0 requests, 0 history rows) got both requests' rows by list (total 2), by another request's request_id pin, and by id (200). A submitter got the other submitter's rows too. Step 2: request-read-gate.ts now has one module-private binder (bindVisibilityGate) behind bindRequestReadGate (unchanged, keyed on id) and the new bindRequestChildReadGates (sys_approval_action and sys_approval_approver, keyed on request_id), both fed by requestVisibilitySourceOf(service), the approvals door's own visibleRequestIds. No second rule. A request_id-pinned read asks the targeted {requestId} shape (listActions' rule). An id-pinned row read asks {row}: the source resolves the row's request through the new module function findChildRequestId, which authorizeFileRead now shares. Untargeted reads get the participant set. It fails closed. Administrator and system reads are unnarrowed (the source answers null before any read). A hidden by-id row answers 404 RECORD_NOT_FOUND exactly like a missing one. The record-reader tier now refuses to anchor on a child-table row (addRecordReaderVisibleIds), because it re-entered unboundedly (ablation B). The published ApprovalService type is unchanged, src/index.ts is untouched, and no packages/spec path changed. Post-fix HTTP re-measure at e6d4665: the granted non-participant gets 0 rows on both tables and 404 by id; the submitter gets own rows only; the granted current approver gets both requests' rows, matching the approvals door; no grant still gives 403.",
    "tests": "Red first, at the unmodified code with only the new file added: request-child-read-gate.integration.test.ts gave 17 failed / 8 passed (25); the 8 are positives and controls. Green: both gate files gave 41 passed (25 new + 16 parent). Final head 7721d61 (merges origin/main d748ae8, spec-only): pnpm turbo run build --concurrency=1 73 tasks exit 0; pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 67 files / 988 passed exit 0; pnpm --filter @objectstack/plugin-approvals typecheck exit 0 (check:test-typecheck: 8 files / 324 errors / 27 signatures held; the new file adds none). Ablations, each from committed head 7721d61 through scripts/ablation-replace.mjs (anchor x1 -> x0, blob changed; restored to the HEAD blob with git diff HEAD empty; the driver re-proved all 3 blobs == HEAD after every leg under its own trap EXIT INT TERM; the subject resolves through relative src imports, so no dist is on the path and no rebuild applies): A child gates unwired => 17 failed / 24 passed, first 'expected { ids: [ …(4) ], total: 4 } to deeply equal { ids: [], total: +0 }'. B anchor guard not covering child tables => 2 failed (both fuses), 'expected 9 to be 1'. C catch fails open => 3 failed (parent + both children fail-closed pins). D row-by-id target dropped => 3 failed (tier-on by-id pins + tier-on parity), 'expected null to be aact_…'. E request_id target dropped => 3 failed (tier-on request-list pins + tier-on parity). D and E move only tier-on pins; with the tier off the targeted and untargeted answers are identical, the expected direction. Lint, narrowed at 7721d61: eslint --no-inline-config --format json over the 4 changed .ts files gives 4 files, 0 errors, 0 warnings; population from eslint.config.mjs (isPathIgnored false for all 4); no parserOptions.project/projectService, so type-aware linting is off and no untouched file's verdict can move.",
    "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 7721d61 (no paths; change set 5 paths vs merge base 76bc1e0, 623 changed lines) derives 67, a superset of the dispatch's list (it adds the adr-0087-registration, empty-changeset, dts-emitted --self-test, release self-tests, spec check:duration-unit-keys, dispatcher-error-vocabulary, engine-double-contract, error-status-conformance, objectql-double-limit, objectui-changeset, pm-changeset-deadline-census, query-options-erasure, type-check-coverage, type-check-debt and where-matcher families). All 67 ran after the full build, each exit captured before any pipe: 67 x exit 0. dispatch-gates --ran with recorded exit codes: '67 derived, 67 run, 0 NOT-MEASURED (a DERIVED zero — all 67 recorded an exit code and none of them is 3)'. Sample verdicts: check:nul-bytes OK (10604 files); check:i18n OK (9 packages); check:dual-build-cjs-loads 107 entries / 66 packages; system-context-census, tenant-audit-census and platform-object-tenancy-census OK with no regeneration. CI on PR #22613 at 05:33Z: 9 success, 3 skipped, 19 in_progress, 0 failed; not waited on.",
    "line_budget": "623 changed lines (+593 / -30, 5 files) vs the human-merge threshold 3000: under. No governed surface, no skills/**, no packages/spec.",
    "files_changed": [
    ".changeset/22589-approval-sibling-read-gates.md",
    "packages/plugins/plugin-approvals/src/approval-service.ts",
    "packages/plugins/plugin-approvals/src/approvals-plugin.ts",
    "packages/plugins/plugin-approvals/src/request-child-read-gate.integration.test.ts",
    "packages/plugins/plugin-approvals/src/request-read-gate.ts"
    ],
    "deviations": [
    "A row read by id carries the caller's whole visible set as its request_id conjunct: the row's request is resolved inside the source, after the override check, so system and admin reads cost no extra read. The parent's by-id conjunct carries one id. This is correct and bounded by APPROVER_INDEX_CAP. It is stated in the PR's Acceptance notes.",
    "The record-reader tier's anchor guard now also refuses sys_approval_action and sys_approval_approver. That narrows the approvals door too, only for a deployment naming those objects in recordReaderVisibleObjects; no in-repo caller does, and serve constructs the plugin with no options. It is required against the unbounded re-entry ablation B measures.",
    "authorizeFileRead now reads the row's request through the shared findChildRequestId (adds a request_id projection); behaviour unchanged, and its existing tests pass.",
    "Merged origin/main d748ae8 (spec i18n-resolver only) before opening the PR (AGENTS §10); git fetch origin main advanced the shared origin/main ref.",
    "The reach probe was a scratch dogfood test file in the worktree, run locally twice and never committed; it was deleted before the gate derivation, and a copy is kept in the scratchpad.",
    "A shell-variable scoping slip appended one line to /checkpoint.log at the filesystem root. That file pre-existed with a line from another run at 01:20Z. Removing it was refused by the harness safety check and was not retried; it is left in place (2 lines, no repo file affected).",
    "The lock answered 99 (queue-timeout) twice behind other holders and was re-acquired under the same slot; neither attempt is counted as a measurement.",
    "Worktree removed after the PR opened (node_modules first, no --force needed; the tree was clean at 7721d61, equal to the remote head)."
    ],
    "mcp_calls": "0 — no MCP GitHub tool used; reads went through gh api (REST GET)",
    "api_writes": "3 relay strokes, each POST /repos/objectstack-ai/objectstack/dispatches -> fleet-write.yml as objectstack-fleet[bot]: (1) pr_create POST /repos/objectstack-ai/objectstack/pulls -> #22613, draft, body read back identical (11216 bytes) by the relay and again by gh api GET; (2) assign POST /repos//issues/22613/assignees (zhuangjianguo) via scripts/pm/label-write.mjs, read back matching; (3) this report, POST /repos//issues/22589/comments. Zero label writes (none named; skip-changeset does not apply because a changeset ships); the PR body was not PATCHed. Plus git push x3 (empty branch probe, e6d4665, 7721d61; not REST).",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · reach: exception: security (possible data exposure; HTTP reach NOT MEASURED — the scene held 0 token rows, the control answered 403 and a granted member 200 with 0 rows) · evidence: sys-approval-token.object.ts declares enable.apiMethods ['get', 'list'] with request_id, approver_id, token_hash and expires_at columns, and no read middleware is bound for it (git grep registerMiddleware in plugin-approvals: the payload-redaction, payload-predicate-guard and request-read-gate seams only). Under an app read grant, the generic door would serve every action-link token row of the organization. Not folded in: who may see token rows is a policy question (the approvals door serves token rows to nobody; request visibility would serve them to participants). Same family as this card; the seat decides single card vs family close-out · dedupe words: sys_approval_token data door read gate; approval action link token generic door; sys_approval_token visibility",
    "carrier: 承接者:无 · noted, not filed — on a default deployment no baseline grants read on the three approval tables, so the request record page's own data-door read and its Timeline answer 403 to the page's declared audience (submitter, approvers) unless an app grants read. Measured on HTTP in both probe runs; it predates this PR, and #22559's card measured the request half. With all three tables now narrowed, a baseline grant would serve only visible rows; whether to add one is a product call. In PR Acceptance notes."
    ]
    }

  4. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT (seat review): PR #22613 at head 7721d61e0c. The generic data door serves sys_approval_action and sys_approval_approver rows by their request's visibility

    domain:services seat 1 (#6021) · session_013j5gkUCpqQiti4GgPqqmnt · 2026-10-10T05:49Z. Claim 6093591709. Report 6094256827. Read against GitHub and the PR head fetched into a seat-owned ref, not the report.

    Disclosure (a security card): the report and the PR body name caller classes, tables, positions and functions only. The reach table gives outcomes per class, with no request, endpoint payload or step sequence. No reproduction recipe. ✓

    Review route. Clause-②: no. The published surface is unchanged:

    • src/index.ts does not re-export request-read-gate.ts, and does not re-export the new module function findChildRequestId.
    • No member is added to ApprovalService.
    • No packages/spec path is touched.
    • No governed text (check-governed-merges --pr 22613: NOT governed, 623 changed lines).

    So, as for the parent (#22559, PR #22587), the review is a seat read plus CI.

    The change, as read in git diff origin/main...7721d61e0c

    • One gate body. A module-private binder holds the middleware once:
      • bindRequestReadGate keeps its signature and its key, id.
      • The new bindRequestChildReadGates binds both child tables on request_id.
      • Both ask the same in-package source, the approvals door's own visibility. ⛔ No second rule.
    • Which request a read names. A read pinned by request_id asks the targeted shape listActions uses. A read by id resolves the row's request through findChildRequestId, which authorizeFileRead now shares, so the two doors cannot disagree about which request a row belongs to. Anything else asks the participant set.
    • Who is unchanged. It fails closed. Administrator and system reads are not narrowed. Every engine read of the two tables in approval-service.ts runs as the system (read at each site), so the engine's own paths are unchanged.
    • Absence, not refusal. A hidden row read by id answers 404 RECORD_NOT_FOUND, exactly as a missing one does. A list's total and a grouped count are narrowed with its rows.
    • The tier's anchor guard now also refuses a child-table row, because otherwise a row's gate re-enters itself (ablation B). It moves only a deployment that names those tables in recordReaderVisibleObjects. An approval request on an approval action row does not arise, and no in-repo caller names them.

    Evidence read

    • Reach measured over HTTP before any code: a granted non-participant was served both requests' rows on both tables. After the fix: 0 rows, 404 by id. The submitter sees only their own request. The granted approver keeps both requests. No grant still answers 403.
    • Red first: 17 failed / 8 passed on the unmodified code, and the 8 are the positives and controls.
    • Five ablations from the committed head, each restored to a blob equal to HEAD. Each turns its own pins red: A 17, B 2, C 3, D 3, E 3.
    • 67 derived gate commands, all exit 0. 67 files and 988 tests pass. Typecheck exits 0.

    CI at 7721d61e0c: 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), all rostered; the diff touches none of their surfaces). mergeable_state: clean. The docs-drift advisory names five pages that mention the tables. The seat read approvals.mdx for the table names: no sentence there describes a generic-door read of either child table, so none is falsified.

    Dispositions of the dev's findings

    Landing: ready, then auto-merge through the queue, in this act. On MERGED, #22616 unlocks.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 (#6021) · session_013j5gkUCpqQiti4GgPqqmnt · 2026-10-10T06:24Z. ⛔ Classes, positions and functions only.


    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:servicespriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions