Skip to content

feat(plugin-approvals): ApprovalService.handleActionPage serves the ADR-0043 action page from a Request (segment 4 of #22438) - #22641

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22578-approvals-action-page-member
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22578-approvals-action-page-member

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22578
Clause-②: yes

Segment 4 of 4 of the maintainer's ruling A on #22438 (director record 6079645593, 「A + 扫类」; split request 6092059305). It implements segment 1's member (#22575, landed as b27b89474d). Dispatched by the domain:services seat 1 (seat post #6021), claim 6094944537, session session_013j5gkUCpqQiti4GgPqqmnt.

What changes

  • ApprovalService.handleActionPage(request) (packages/plugins/plugin-approvals/src/approval-service.ts) implements the optional IApprovalService.handleActionPage member: the ADR-0043 action page from a web-standard Request to a Response.
    • GET reads token from the query string and renders the confirm page, or the result page for a dead link. It never decides.
    • POST reads token from the form body and redeems it. A repeated token field resolves to its last value, the way the mount's body parser reads it. A body that is not a form carries no token and is told "invalid".
    • A dead link (unknown, expired, consumed, decided, reassigned) is told on the page with 200.
    • Any other method: 405 with Allow: GET, POST. Neither the contract docblock nor ADR-0043 names a third method, so this is the narrowest honest answer.
    • It goes through the same peekActionToken / redeemActionToken and the same renderers (action-link-pages.ts, unchanged) as the mount. There is no second token check, no second page text and no copied redemption chain. It reads no context, session, cookie or Authorization header.
    • The confirm page's form posts to /api/v1/approvals/act, the mount's path. actionLinkUrl now reads the same module constant (its output bytes are unchanged).
  • The self-hosted raw-app mount (approvals-plugin.ts, mountActionPages): its route handlers are byte-unchanged and it does not call the member. The one edit is one info line in the no-raw-app branch, saying the pages are served through the HTTP dispatcher's /approvals/act domain (it was silent). There is no double mount.
  • Test infrastructure: hono joins plugin-approvals' devDependencies (the workspace's ^4.13.9; the lockfile resolves it to the existing 4.13.12), so pin 2 drives the mount through a real Hono app.
  • Changeset: .changeset/22578-approvals-action-page-member.md, minor.

Parity evidence

src/action-page-member.integration.test.ts boots a real ObjectKernel: ObjectQLPlugin, the real ApprovalsServicePlugin, and @objectstack/driver-sql on in-memory better-sqlite3. A host plugin gives http.server a real new Hono() as its raw app, so the mount is the plugin's own mountActionPages, registered at kernel:ready. The member is read off the started approvals slot. Both doors sit on one token store.

For each case, the same Request goes to the member, then to app.fetch. The test compares status, the full header list and the body as a Uint8Array:

  • GET: a live approve token and a live reject token (the confirm page, which carries the token and the form action), and an unknown, an expired and a consumed token.
  • POST: an unknown, an expired and a consumed token; a JSON body; a text/plain body; and a repeated token field whose last value is dead (both doors answer "invalid", and the live first value stays live at both).
  • POST, live: two twin requests (same flow, step, record title and approver), one token each, one redeemed per door. The pages are byte-equal, and both requests are decided as the bound approver.

The mount on main, measured directly. The parity and one-store pins were also run with approvals-plugin.ts put back to main's exact file. scripts/ablation-replace.mjs deleted the one added line. The on-disk blob was 51fd00eb78, equal to main e8c6666870's blob for that path. The result was 5 passed, 7 skipped by -t "parity|one token store". The file was then restored to the HEAD blob 76cfb41fe7, and git diff HEAD was empty. So the member's bytes equal the bytes of the mount that ships on main, not only of this branch's copy.

Pins

  1. The member renders and redeems from a Request. It covers a live token's confirm page (200, text/html; charset=utf-8, form action /api/v1/approvals/act). GET never decides: after 5 GETs the request is still pending with no decision row, and the token is still live (the next POST decides). A POST approve and a POST reject decide as the bound approver: the action row has actor_id and acted_as set to the approver, not a system actor, and the flow resumes down the matching edge. Dead links (unknown, expired, consumed) answer 200 with the result page through GET and POST. A non-form body is told "invalid". PUT, DELETE, PATCH, HEAD and OPTIONS answer 405 with Allow: GET, POST.
  2. Parity: see above.
  3. One token store: a token consumed through the member is consumed through it again, with exactly one decision row. A token redeemed through the mount is dead through the member (GET and POST), and the other way round.
  4. The token is the only credential: a POST that carries Authorization and Cookie headers for someone else decides as the bound approver. A spy over the request's Headers records no authorization or cookie read.
  5. The composition: the member is on the started service with a raw app and without one. With a raw app, app.routes holds exactly GET and POST on the path, plus the existing "mounted at" line. Without one, nothing is mounted and the new line is logged once.

Ablation (this layer)

The member was removed with scripts/ablation-replace.mjs, which renamed async handleActionPage( to async handleActionPage_ablated(. The anchor went from 1 hit to 0, and the blob went from 39ec14d8d5 to 3c1005a2d3. The run was vitest run src/action-page-member.integration.test.ts: 12 of 12 failed. Ten failed with TypeError: svc.handleActionPage is not a function, and the two composition pins failed on typeof being undefined. Restored: the blob is back to 39ec14d8d5, equal to HEAD's, and git diff HEAD is empty. The ablation ran at caed7aa69f. approval-service.ts, approvals-plugin.ts and the test file have the same blobs at the final HEAD 60b4b97f37.

NOT MEASURED: the cross-segment ablation

The card's third pin ("with the member absent, segment 2's typed refusal answers") is NOT MEASURED. The reason: #22576 (segment 2, domain:cli) has not landed. It is open. On origin/main 1b99388505, git grep -i approvals packages/runtime/src/http-dispatcher.ts exits 1 with 0 hits; the control git grep -c -i auth on the same file at the same ref exits 0 with 104. It belongs to #22576's pins, since the refusal is that domain's.

Verification (HEAD 60b4b97f37; origin/main merged at f368b7e980)

  • pnpm --filter @objectstack/plugin-approvals test: 69 files, 1009 tests passed. The new file passes 12 of 12.
  • pnpm --filter @objectstack/plugin-approvals typecheck: exit 0. check:test-typecheck is OK, and the new test file is in the tsconfig.test.json program (--listFiles counts 1) with zero errors.
  • pnpm --filter @objectstack/spec run test:repo: 54 files, 915 tests passed.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 79 commands, and all 79 ran. --ran reports: 79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN.
    • check:dual-build-cjs-loads and check:i18n first refused with PREREQUISITE NOT MET (exit 3). After a full build (turbo run build --filter='!@objectstack/docs', 69 of 72 tasks cached) both exit 0: 107 require entry points load, and all 9 i18n packages are in sync.
  • Not run locally, as the derivation lists them: CI's own job steps (Test Core shards, Dogfood, Build Core, Temporal Conformance, the type-check lanes) and the workflow-valued families. CI owns those.

Acceptance notes


Generated by Claude Code

…DR-0043 action page from a Request

The transport-neutral member the approvals contract declares: GET renders the
confirm page, POST redeems, from a web-standard Request to a Response, through
the same peek/redeem methods and the same renderers the self-hosted raw-app
mount uses. The mount's route handlers are unchanged; its no-raw-app branch
now says once where the pages are served instead.

Claude-Session: https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 6 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7041c60bca4c1609c8eed634c6e11bab0684d2ff — the merge of head 60b4b97f37ffe387a4e93b681b2bcabe7c3d7304 into base 1b99388505d33c940757e85685ca925d6cd57f04, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7041c60bca4c1609c8eed634c6e11bab0684d2ff && git checkout 7041c60bca4c1609c8eed634c6e11bab0684d2ff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1b99388505d33c940757e85685ca925d6cd57f04 60b4b97f37ffe387a4e93b681b2bcabe7c3d7304 && git checkout -B drift-repro 1b99388505d33c940757e85685ca925d6cd57f04 && git merge --no-ff 60b4b97f37ffe387a4e93b681b2bcabe7c3d7304

node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1b99388505d33c940757e85685ca925d6cd57f04 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 60b4b97f37ffe387a4e93b681b2bcabe7c3d7304
Local-runs: none

PR #22641 on card #22578, segment 4 of the maintainer's ruling A on #22438 (director record 6079645593, split request 6092059305). Inputs, and nothing else: the card body and its four comments (unlock scan 6094525572, claim 6094944537, os-dev-report 6095296313, claim amendment 6095306881); the PR body and its six-file list; the net diff git diff origin/main...60b4b97f37 (+504/-1); and the check-runs on the head. Nothing was built, run or re-run. ADR-0043 and the member's docblock in packages/spec/src/contracts/approval-service.ts were read at the head as the governing text; the locked hono@4.13.12 was read for its parser semantics.

Check-runs on the head: 35 check names, latest run per name, all completed: 33 success, 2 skipped (Console Pin Gate, which runs only when the pin moves, and Packed-tarball smoke (opt-in)), 0 failure, 0 in progress; the last to finish was Lint & Repo Gates at 2026-10-10T08:22Z. Those conclusions are the gate verdicts for every derived family: Check Changeset, Governed Surface Queue Guard, Validate Package Dependencies, Lint & Repo Gates, the four Type Check lanes, Build Core, Build Docs, Test Core (six shards and the gate), Dogfood (three shards, the gate and Verify CLI), Temporal Conformance, and the card, branch and single-writer guards. No verdict was rendered while any of them was still in progress.

① Derived judgments

  1. A new public member on the published class — right. ApprovalService.handleActionPage(request: Request), returning a Promise of a Response, matches the optional contract member's signature exactly; the class already declares implements IApprovalService, so the implementation is checked by the type-check lanes, which are green. No other export changes: ACTION_PAGE_PATH and actionPageResponse are module-private.
  2. GET accept-set — right. The token is read from the token query parameter (first occurrence, as URLSearchParams.get and Hono's query() both read it), peekActionToken is the one check, a live token answers the confirm page and a dead one the result page, both 200 with Content-Type: text/html; charset=utf-8, and nothing is decided. This is the contract's GET and the mount's GET.
  3. POST accept-set, repeated field read as its last value — right. The mount reads parseBody() then body.token; in the locked Hono, parseFormData assigns form[key] = value per entry in order unless all is set, so a repeated key resolves to its last value. The member reads formData().getAll('token') and takes the last, so the two doors agree on every body. A non-form body is {} at the mount and a thrown formData() at the member, both an empty token and the "invalid" page. redeemActionToken is the one redemption chain. The parity pin measures all of this byte for byte on one token store, including the repeated field whose live first value stays live at both doors.
  4. Methods other than GET and POST answer 405 with Allow: GET, POST — right. The contract and ADR-0043 name only GET and POST, and the contract says the one caller forwards only those two, so no third method reaches the member from its caller; for a resource that exists but does not support the method, 405 with Allow is the correct HTTP answer and narrower than inventing GET semantics for a third verb. The mount itself answers a stray method with Hono's not-found and serves HEAD as a bodiless GET at the app level; neither is a contract behaviour, and neither is a parity case. HEAD is carried to runtime + hono: an exact /approvals/act dispatcher domain that forwards to the approvals service member, and a catch-all that stops consuming non-JSON bodies (segment 2 of ruling A on #22438) #22576 (see ③).
  5. Same token checks, no second credential — right. The diff adds no token lookup, no page text, no copied chain, and no read of request.headers, a context, a session or a cookie; the member imports the two renderers the mount already imports. The pin-4 header spy can only observe JavaScript-side reads, so this judgment rests on the diff, which shows none.
  6. Byte-unchanged mount, no double mount — right. The pre-image of approvals-plugin.ts in the diff is main's blob (51fd00eb78), and the only hunk is one inserted statement before the existing early return; both route handlers are untouched, the member is not mounted, and the composition pin reads exactly GET and POST on the path.
  7. The one added log line — ordered by the card, honest only once runtime + hono: an exact /approvals/act dispatcher domain that forwards to the approvals service member, and a catch-all that stops consuming non-JSON bodies (segment 2 of ruling A on #22438) #22576 lands. It says the pages "are served through the HTTP dispatcher's /approvals/act domain". On origin/main 1b99388505 the dispatcher has no approvals domain, so between this landing and runtime + hono: an exact /approvals/act dispatcher domain that forwards to the approvals service member, and a catch-all that stops consuming non-JSON bodies (segment 2 of ruling A on #22438) #22576's a kernel with no raw app logs a route that still answers ROUTE_NOT_FOUND. The card's own text prescribes this line, the dev declared the window in the PR body and the report, the line is info-level and carries no tracker number, and the fix is landing order rather than a change to this diff. Judged right as ordered; escalated in ③.
  8. actionLinkUrl — no surface change. The constant extraction emits the same bytes.
  9. Test and package surface — right. The test boots inside each case, imports only this package's sources and its devDependencies, reads nothing outside the package, and is in the tsconfig.test.json program that the package's typecheck script names through check:test-typecheck; all four Type Check lanes are green. Nothing under dist/, exports or files changes.
  10. Nothing governed, nothing out of lane. None of the six paths is a governed surface (Governed Surface Queue Guard green); packages/spec, packages/runtime, plugin-hono-server and content/docs/releases/ are untouched, as the claim required.

② Semver level

Clause-②: yes

.changeset/22578-approvals-action-page-member.md declares @objectstack/plugin-approvals: minor and carries Clause-②: yes, as the PR body does. The diff publishes one additive public member on a published class, which is yes and at least minor; it removes or renames nothing, so no arm and no ADR-0087 marker is owed; skip-changeset would be wrong. Check Changeset is green. The hono devDependency publishes nothing: devDependencies are not installed by consumers, and dist/, exports and files are unchanged. One observation, not a ground: the lockfile row records specifier: ^4.13.5 under a manifest that says ^4.13.9; the three sibling importers on main (adapters/hono, plugin-auth, plugin-hono-server) carry the identical pair, and CI's pnpm install --frozen-lockfile under the pinned pnpm is green on this head and on main, so the row matches its siblings rather than its own manifest and the install accepts it. A repo-wide lockfile refresh, if wanted, is its own card.

③ Boundary flags

Implemented-by: claude/issue-22578-approvals-action-page-member
Reviewed-by: session_013j5gkUCpqQiti4GgPqqmnt (contract-review subagent, CONTRACT_REVIEW_TIER)

VERDICT: PASS

Rendered 2026-10-10T08:23Z on the head named above; the check-run reading is the one declared in the summary line.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 10, 2026 08:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 10, 2026 08:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit 3d0eeef Oct 10, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22578-approvals-action-page-member branch October 10, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants