Repository navigation
plugin-approvals: sys_approval_token (the action-link tokens) is served on the generic data door with no read narrowing, while the approvals door serves its rows to nobody #22616
Description
This card carries the third request-keyed table of the approval engine. Its family: #22559 (sys_approval_request, landed 76bc1e03a3) and #22589 (sys_approval_action and sys_approval_approver, PR #22613). Reported by #22589's dev as an out-of-scope finding (os-dev-report 6094256827). Filed by domain:services seat 1 (seat post #6021, session_013j5gkUCpqQiti4GgPqqmnt).
Unblocked: #22589 closed (PR #22613, 83b8b80728 on main); a gate-shaped fix sits beside the child-table gates that landed there.
Filing class: ① product defect, security. reach: exception, possible data exposure. ⛔ Not measured through HTTP: the dev's scene held no token row (control 403 with no grant; 200 with 0 rows under a grant). 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.
Lane and priority: domain:services, inherited from the in-flight family card. priority:p2, one below the family's p1: the digest is already withheld, and what remains exposed is the link-to-identity linkage, reachable only under a grant no shipped set makes.
The gap (code evidence, read on origin/main b27b89474d)
sys-approval-token.object.tsdeclaresenable.apiMethods: ['get', 'list']. Its own comment there says the tokens are "minted and consumed by the approval engine (SYSTEM_CTX), never via the data API" (ADR-0103, engine-owned).- A row holds
request_id,approver_id(the identity the link is bound to),action,expires_atandconsumed_at.token_hashisinternal: true([security] The compliance ledger stores a JWT signing-key row's key material in its create snapshot, and an admin is served it through the ledger's by-id door while the key object itself declares no API door #21197), so the digest is already withheld from every generic exit. plugin-approvalsbinds no generic-door read middleware for this object. After PR fix(plugin-approvals): the generic data door serves sys_approval_action and sys_approval_approver rows by their request's visibility (#22589) #22613, the bound read gates aresys_approval_request,sys_approval_actionandsys_approval_approver.- The approvals door serves token rows to no caller. The engine only mints, looks up by digest and consumes them, all as the system.
- So, under an app's read grant on the object, the generic door would serve the organization's whole set of rows: which identity holds a live approve or reject link for which request, and until when.
Ask
- Measure reach. With at least one token row present and an app read grant on the object, what does the generic door serve a member who takes part in no request? Use no grant as the control. Also measure who reads the object through the generic door today, in this repo, in the pinned console and in Setup or Studio. The readers census decides the shape in step 2.
- If it serves rows: the generic door serves token rows to no caller, as the approvals door does.
- Why: one operation has two implementations, and the governed one (the approvals door) wins. The object's own declaration already says "never via the data API".
- Preferred shape, if the census finds no reader: retire the read methods from the declaration, so what is declared is what is enforced. This is a narrowing: declare
Clause-②accordingly, with a changeset FROM → TO. - If a reader exists: stop and report it as a fork. ⛔ Do not keep the reader by inventing a visibility rule for tokens.
- Administrator and system behaviour is part of the census. The engine's own reads run as the system and must be unchanged.
- Pins: a granted member is served no token row, by list, count or id. The engine's mint, lookup-by-digest and consume path is unchanged (an action link still redeems once). Each negative pin is ablation-verified.
Order: after PR #22613 lands, because it adds the child-table read gates and APPROVAL_REQUEST_CHILD_OBJECTS in request-read-gate.ts. Region-level overlap in approvals-plugin.ts and request-read-gate.ts if the claimant reaches for a gate rather than the declaration.
Dedupe: MCP search_issues, this repo, sys_approval_token generic data door read gate action link token rows visibility → 4 hits. #22589 is the sibling card, and #22559 the parent (closed). #21197 (closed) withheld the digest column. #8580 (closed) is unrelated. None is this gap.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 10, 2026
State:
pm:queue→pm:blocked·domain:servicesseat 1 (#6021) ·session_013j5gkUCpqQiti4GgPqqmnt· 2026-10-10T05:38ZBlocked-by: #22589is now in the body. A gate-shaped fix would sit beside the child-table gates that PR fix(plugin-approvals): the generic data door serves sys_approval_action and sys_approval_approver rows by their request's visibility (#22589) #22613 adds inrequest-read-gate.tsandapprovals-plugin.ts, so this card unlocks when plugin-approvals:sys_approval_action(the decision log) andsys_approval_approver(the approver index) are served on the generic data door with no read narrowing, the sibling half of #22559 #22589 closes.Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
Claim: PM loop round 1 · 2026-10-10T06:27Z
Session:
session_013j5gkUCpqQiti4GgPqqmntAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-22616-approval-token-generic-doorWorktree:
objectstack-issue-22616Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)Unblocked in this act: #22589 closed (PR #22613,
83b8b80728onmain).pm:blocked→pm:dispatchedin one write, and the body'sBlocked-by:line is replaced by anUnblocked:line.File surface, read on
origin/main83b8b80728:packages/plugins/plugin-approvals/src/sys-approval-token.object.ts,enable: the read methods retire from the declaration, so the generic door serves token rows to no caller.apiMethodsmeans on this engine. If absent means "everything", the retirement must be spelled so it closes the door.plugin-approvals: a granted member is served no token row, by list, count or id; an action link still redeems once. Plus the package's translation bundles, only if the declaration change moves them, regenerated by the repo's tooling..changeset/22616-*.md: at the level the diff publishes, with FROM → TO.packages/spec. ⛔ Norequest-read-gate.tsorapprovals-plugin.tsunless the census forces a gate (then stop and report first). ⛔ Nocontent/docs/releases/.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; the default tier builds; a narrowing owes oneCONTRACT_REVIEW_TIERreview before the queue)Clause-②: no (narrowing)
Responsibility:
this repository's plugin-approvals: the token object declares get/list on the generic door with no read narrowing, while its own comment says the tokens are never read via the data API | the approvals door, which serves token rows to nobody, and #21197, which withheld the digest | any member an application grants read on the object; no shipped set grants itThread-read: 6094288090
Serial constraints cleared:
manager_id#22607, this seat) editsapproval-service.ts(openNodeRequest) and its tests. Disjoint files; whichever lands later mergesmain.plugin-approvals(2026-10-10T06:27Z, the file lists of all open PRs read).Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
os-dev-report
{
"issue": 22616,
"status": "done",
"branch": "claude/issue-22616-approval-token-generic-door",
"pr": "#22633",
"session": "session_013j5gkUCpqQiti4GgPqqmnt — subagent run, so this is the parent's harness-stamped id",
"premise_still_valid": true,
"summary": "Both premises held. Reach was measured on a real stack (bootStack with the real SecurityPlugin and ApprovalsServicePlugin; the token rows were minted by remind()). Under an app read grant, a member in no request was served both token rows by REST list (200, total 2), by id (200), by query (200) and by the cross-object search (2 hits). The administrator was served the same, and no grant answered 403. Readers census: nothing reads the object through a generic door (repo git grep; pinned console 20c6d351ad: 0 hits, with a positive control; no Setup or Studio nav, view or page). One internal QA checklist procedure reads token rows as the administrator, as an oracle; it is reported, and it is not a product reader. Fix: the enable block of sys-approval-token.object.ts moves from apiMethods ['get','list'] to apiEnabled: false plus apiMethods: []. From source: apiEnabled: false is the declared off switch, judged first by apiExposureDenialReason (REST, dispatcher, MCP) and the only flag searchAll reads; apiMethods: [] is the declared deny-all, while an absent whitelist reads as unrestricted. After the fix every caller gets 404 OBJECT_API_DISABLED on list, id, query and export, and 0 search hits. The engine's mint, lookup-by-digest and consume path is untouched: the link still redeems once and is refused on replay. Two dispatch expectations did not hold as worded. The no-grant control does NOT answer as before: it moves from 403 PERMISSION_DENIED to 404 OBJECT_API_DISABLED, because the exposure gate is judged before the permission check. And restoring get and list alone turns only the declaration pin red, by design, because the switch is judged first; the full revert (ablation A) turns all three negative pins red. The analytics door still serves the token rows; that is reported below as a security finding outside this card's file surface.",
"tests": "Pins: plugin-approvals src/sys-approval-token-generic-door.integration.test.ts, 6 cases. Red first at c8584b9 on the unmodified declaration: the 3 negative pins (declaration, every operation api-disabled, real-engine search) are red, and the scene, the search control and the positive control are green. Green at 9ecf41b, and at dd03747 (7/7 with the internal-hash test). Ablation through scripts/ablation-replace.mjs (anchor hit x1, blob changed; each restore proven by the blob equalling HEAD abe8c71c89, with git diff HEAD empty; the subject is imported from source, so no build sits between mutation and run). A, both halves back to the original: 3 red. B, get and list restored with the switch kept: only the declaration pin red. C, the switch dropped with apiMethods [] kept: 3 red, and the search serves both token rows. HTTP probe (scratch, not committed; plugin-approvals rebuilt, and ablation-dist-preflight confirmed the fix marker in dist index.js and index.mjs). Before 83b8b80: the granted member got list 200 (n=2, total 2), byId 200, query 200, export 403, search 2 hits; the admin the same; no grant 403 PERMISSION_DENIED. After 9ecf41b: every persona got list, byId, query and export 404 OBJECT_API_DISABLED, and search 0 token hits. Redeem, on both runs: first approved, second 'already used', request approved, 1 row consumed. Package: plugin-approvals vitest 68 files / 994 tests passed at 27c9455. Typecheck (tsc, the scripts project, check:test-typecheck) exit 0 at dd03747, with the test-layer debt unchanged (8/324/27). spec test:repo 54 files / 915 tests passed at 27c9455. Gates: dispatch-gates --commands derived 65 at dd03747, and all 65 exited 0, each exit captured before any pipe. --ran: 65 derived, 65 run, 0 unrun. The first sweep had check:query-options-erasure red (the test surface went 236 to 237 through one any-erased bag in the new test); dd03747 fixed it and the surface is back at 236. check:i18n and check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET) until the closure build, then passed: i18n 9 packages in sync; dual-build 107 entry points load. Lint, narrowed: eslint --no-inline-config on the 2 changed .ts files. Both are in the population (--print-config resolves 6 and 5 rules); --format json gives 2 files, 0 errors, 0 warnings; there is no parserOptions.project or projectService, so untouched files' verdicts cannot move. The repo-wide pnpm lint is CI's. CI at report time: 14 checks completed with 0 failures, 17 in_progress; not awaited.",
"mcp_calls": "0 — no MCP GitHub calls",
"api_writes": "3 — each one relay dispatch (POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft, #22633, body read back byte-identical); assign = POST /repos//issues/22633/assignees (zhuangjianguo, read back); comment = POST /repos//issues/22616/comments (this report). Three git pushes (the empty-branch probe, 27c9455, dd03747) are not REST writes. Reads: single-card REST reads, plus a depth-1 clone of the public objectui at the pin.",
"open_questions": [
{
"question": "The internal QA checklist item approvals.email-action-token-door (docs/qa/platform-checklist/areas/approvals.json) reads token rows as the administrator through the data API as an oracle, and writes expires_at through it for its expiry leg. After this PR those steps answer 404; two of them already contradicted the door before (the digest has been withheld since #21197, and update was never in the whitelist). It is outside this card's file surface. Amend it here?",
"options": [
"A — extend the file surface to that checklist item and, in this PR, move its oracles to the act door's outcome pages, the request's action history and the internal-hash integration test",
"B — leave it to the next checklist-author sweep (carrier), recorded in the PR's Acceptance notes"
],
"recommendation": "A, because the procedure now contradicts the door it checks: a later checklist run would read the 404 as a defect and push to reopen the door. The text is internal QA, neither governed nor published, and it should land with the change that moved it."
},
{
"question": "No HTTP-level pin is committed. The claim scoped tests to plugin-approvals, which depends on neither @objectstack/rest nor @objectstack/verify. Should an end-to-end pin be added elsewhere?",
"options": [
"A — no: the pins hold the declaration through the spec decision every door delegates to, plus a real-engine search pin, and REST and MCP already pin the api-disabled to 404 OBJECT_API_DISABLED envelope in their own suites",
"B — add a bootStack pin in packages/verify or the dogfood suite, covering list, count and id for a granted member and an administrator"
],
"recommendation": "A, because every link is already pinned where it lives, and a second end-to-end suite adds a verification surface without adding a fact."
}
],
"out_of_scope_findings": [
"class: a · reach: public door POST /api/v1/analytics/query (the ad-hoc cube that inferCubeFromQuery mints), measured on the real stack after this fix: 200 serving rows of an apiEnabled: false object and columns declared internal: true. The token object's dimension rows and its withheld digest column went to a member holding read and to an administrator, and another apiEnabled: false credential store's internal private-key column went to an administrator. exception: security (possible data exposure) · evidence: service-analytics (analytics-service.ts, inferCubeFromQuery and the ad-hoc query path) reads neither enable.apiEnabled nor a field's internal flag; the package contains no apiEnabled reference. The generic data door for the same objects answers 404 OBJECT_API_DISABLED and strips internal columns (#21197). One door, two facets; one card. · dedupe words: analytics ad-hoc query internal field; inferCubeFromQuery apiEnabled false; analytics cube withheld column; analytics door credential store",
"carrier: PM, or the next checklist-author sweep · the internal QA checklist item approvals.email-action-token-door reads and writes token rows through the data API as an oracle, and now answers 404. Two of its steps already contradicted the door before this PR: the digest has been withheld since #21197, and update was never in the whitelist. Noted in Acceptance notes, not filed; see open question 1.",
"carrier: 承接者:无 · ObjectStackProtocolImplementation.searchAll skips an object only on searchable === false or apiEnabled === false, and never reads apiMethods, so a whitelist that omits list does not keep an object out of the cross-object search. Dormant: the three whitelists without list (sys_verification, sys_device_code, sys_two_factor) all declare searchable: false. Ablation C shows the shape on a counterfactual. Noted in Acceptance notes, not filed."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
ACCEPT (seat review): PR #22633 at head
dd037479aa.sys_approval_tokenis no longer exposed through the automatic APIdomain:servicesseat 1 (#6021) ·session_013j5gkUCpqQiti4GgPqqmnt· 2026-10-10T07:45Z. Claim6094652078. Report6095111901. Read against GitHub and the PR head fetched into a seat-owned ref, not the report.Disclosure (a security card). The report, the PR body and the changeset name caller classes, doors, functions and object kinds. They contain no request body, no step sequence and no stored value. ✓
Review route.
Clause-②: no (narrowing). The PR owes one contract-review-tier record, and it is on the PR (6095252837):Served-tier: CONTRACT_REVIEW_TIER;dd037479aa1af849a2af54907082b17062ddb24c;Local-runs: none;The PR is not governed (
check-governed-merges --pr 22633: 276 changed lines).The change, as read in the diff. In
sys-approval-token.object.ts,enablemoves fromapiMethods: ['get', 'list']toapiEnabled: falseplusapiMethods: []. The other two files are one integration test and the changeset.apiEnabled: falseis the off switch.apiExposureDenialReasonjudges it first for REST, the dispatcher and MCP, and it is the one flag the cross-object search reads.apiMethods: []is the declared deny-all. An absent whitelist would read as fully open.404 OBJECT_API_DISABLED. A no-grant caller moves from403to404, because the exposure gate is judged before the permission check. The review calls that the stronger posture: it is uniform and gives no existence signal.sys_flow_credential, thesys_oauth_*stores,sys_jwks).Evidence read.
plugin-approvals: 994 passed. Spectest:repo: 915 passed.CI at
dd037479aa: every check completed, none red. The seven required contexts are green.Build Docs,Console Pin GateandPacked-tarball smoke (opt-in)were skipped, all rostered.Dispositions
approval.requested(#22607): ADR-0043's v1 note, the automation skill's approval advice, and the QA checklist's known gap #22631, which names the item, the404and the remedy (6095140961).404envelope is pinned in the REST and MCP suites.apiEnabled: falseobjects andinternal: truecolumns: filed as security(analytics): the ad-hoc analytics query serves objects that declareapiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634 (security, p1), dispatched in this round. The review judged this PR's narrowing honest as framed: its FROM → TO lists exactly the doors it closes. The changeset's plural "generic doors" is fully true once security(analytics): the ad-hoc analytics query serves objects that declareapiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634 lands.searchAllnever readsapiMethods, so a whitelist withoutlistdoes not keep an object out of the search sweep: the review escalated it as declared-not-enforced. It is dormant today, because every such object also declaressearchable: false. Filed in this act for triage, sincemetadata-protocolis not this lane.#21197comment abovetoken_hash("this object's get/list doors"): a nit. It rides the next edit of that file.Landing: ready, then auto-merge through the queue, in this act.
Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
Landed ·
domain:servicesseat 1 (#6021) ·session_013j5gkUCpqQiti4GgPqqmnt· 2026-10-10T08:06Z. ⛔ Classes, positions and functions only.86da194919. Read onorigin/main:sys-approval-token.object.tsdeclaresapiEnabled: false. ItsFixes #22616closed this cardcompleted.pm:dispatchedand the assignee were cleared in this act.@objectstack/plugin-approvals,minor, BREAKING narrowing):sys_approval_tokenis no longer exposed through the automatic API. Every caller, an administrator included, gets404 OBJECT_API_DISABLEDon the REST data routes, the dispatcher and the MCP data tools.apiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634 (security, p1, in flight): the analytics door still reads neither the off switch norinternal: true.searchAll) skips an object onsearchable/apiEnabledonly and never consults theapiMethodswhitelist, so a whitelist that omitslist(and sosearch) does not keep an object out of the sweep #22640 (triage): the search sweep ignoresapiMethods.approval.requested(#22607): ADR-0043's v1 note, the automation skill's approval advice, and the QA checklist's known gap #22631 (triage): the QA checklist item that read token rows through the data API.Generated by Claude Code