Skip to content

fix(runtime)!: a same-origin type: 'api' action is dispatched through the action doors, so its packaged-action switch holds (#27506) - #27649

Draft
objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/project-thread-lzb7u9
Draft

objectstack-fleet[bot] wants to merge 6 commits into
mainfrom
claude/project-thread-lzb7u9

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Fixes #27506

Ruling-ref: #27506 (comment) (Sales 「同意」 — option C in three steps; this PR is step 2, the server step).
Lands after #27610 (the ADR-0066 D4 / ADR-0126 §8 / spec-docblock text this behaviour rests on). Step 3 is #27611 (the console sends api clicks through this door).

Clause-②: no (narrowing) — no key, accepted value or export of @objectstack/spec changes; an api action that reaches the doors now passes their gates before its target runs, where the console's direct fetch passed none of them.
Metadata-protocol: none
Skills-docs: content/docs/ui/actions.mdx (the type table and the "Call it over REST" dispatch table) now state the same-origin forward and the off-origin refusal; no file under skills/** teaches that an api action is refused by the doors or invisible to MCP, so no skill line changes.

What changed

Switching off a type: 'api' packaged action in the ADR-0126 §8 ledger did not stop it: the console fetched the action's target directly, the /actions door answered 400 for the type, and MCP run_action refused it outright — "Ban User switched off" still banned, and an agent could not do what a human click could.

  • Same-origin api actions are server-dispatched. POST /api/v1/actions/:object/:action and MCP run_action accept a type: 'api' action whose target is a path on this origin. The door runs every gate it already runs — the sign-in floor, ADR-0066 D4 requiredPermissions, the ADR-0126 §8 activation consult, the param contract, the subject-row load — and then forwards the request in-process to the declared target (IHttpServer.getRawApp()'s request injector, the seam @objectstack/verify drives) with the caller's own credentials (Authorization, Cookie, x-admin-session-token, tenant and proxy headers) and the body assembled from the declaration by the console's own rules (recordIdParam / recordIdField from the subject row, refused by name when the row cannot supply it; bodyShape.wrap; the outputs.* fold; organizationId for an /api/v1/auth/ target; bodyExtra last; method, no body on DELETE; ${param.*} / ${ctx.recordId} / ${ctx.user.id} / ${ctx.org.id} / {field} in the path). The target still authorizes the caller itself; its status and body are the door's answer (data unwrapped, as the console's direct fetch handed result.data), and a credential rotation it emits — every Set-Cookie line, set-auth-token, set-admin-session-token — is relayed, so impersonate_user works through the door on the cookie lane and the bearer lane.
  • A disabled api action answers 409 ACTION_DISABLED and its target is never called. A forwarded request that lands back on an action door is refused (400); a ${ctx.selection.*} or {{page.<var>}} token the server cannot resolve is refused 400 by name; a kernel with no in-process request path answers 503.
  • Off-origin api actions stay browser-only, and cannot be switched. Both doors refuse them (400, naming the origin), MCP does not list them, and POST /actions/_activation/:object/:action refuses to flip them (409) with the reason — a ledger row nothing would consult is not recorded.
  • HttpDispatcherResult.response.headers accepts string[] (one header line per element) and the Hono adapter's route res.header appends an array instead of comma-joining it — the only correct spelling for several Set-Cookie lines. Measured: /admin/impersonate-user answers six.

File surface beyond the claim's three (action-execution.ts, domains/actions.ts, domains/mcp.ts): http-dispatcher.ts (the header type, 1 line) and plugin-hono-server/src/adapter.ts (the array append, 10 lines) — both needed for the cookie lane; the probe before the red tests showed six Set-Cookie lines that a Record<string, string> channel could not carry.

Evidence

  • Probe first (per the claim): in-process app.request to /admin/ban-user, /admin/unban-user and /admin/impersonate-user with the caller's bearer, and with the caller's cookie — all 200; sys_user.banned flips; impersonate answers 6 Set-Cookie + set-auth-token (+ set-admin-session-token on the bearer lane), and the caller's old bearer dies (better-auth bearer plugin lets a bearer session silently shadow an impersonation the server just created — /admin/impersonate-user returns 200 and is a no-op for any bearer client #8243).
  • Unit, packages/runtime/src/domains/api-action-forward.test.ts (new) + action-activation-dispatch.test.ts + action-declarative-update.test.ts + action-execution-destructive.test.ts (its "gate 2 excludes the 13 identity actions" pin flips to "gate 2 admits"): 128/128.
  • Dogfood, packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts (new, real showcase + better-auth admin plugin + live MCP): ban_user off → 409 and not banned, on → banned; unban_user; set_user_password (the target signs in with the new password); a member's 403 relayed, anonymous 401; the off-origin flip refused 409 with no ledger row and the run refused 400; MCP run_action on a same-origin api action reaches the exposure gate instead of the type refusal; impersonate_user on the cookie lane (≥2 Set-Cookie lines, one per line) and the bearer lane (rotation headers, old token 401): 8/8.
  • Ablation A (isHeadlessInvokableAction's api arm → false): 17 red (both doors + the destructive pin), restored blob == HEAD. Ablation B (adapter array append → comma-join): the cookie-lane dogfood case red with the six cookies joined into one line, restored blob == HEAD.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 112 families (93 for the diff + 19 the scripts/check-route-envelope.mjs ledger edit adds); every command run with its exit captured before any pipe; --ran reconciliation: 112 derived, 112 run, 0 NOT-MEASURED, 0 UNRUN. Four prerequisites cleared and re-run to 0 (teardown-shape self-test after git fetch --unshallow; dual-build-cjs-loads and skill-examples after building the 8 dists the closure leaves out); two findings fixed in-branch (ADR-0087 disposition marker on the changeset; the route-envelope ledger now declares actions.ts handBuilt 3 — the 405 and the forward's two header-carrying answers).
  • Dogfood battery on the final head: final-closure head f424504 (merge of origin/main 9c51314 onto the fix; the two later commits 80fc341 / 75d7d9e change only the changeset text and the route-envelope ledger script) · os-verify-lock VERDICT command-exit 0 · held 35m38s · Test Files 352 passed | 3 skipped (355) · Tests 2830 passed | 20 skipped (2850) · 0 failures.

维护者速读


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw

os-sales and others added 4 commits October 29, 2026 15:19
…he action doors, so its packaged switch holds

`POST /actions/:object/:action` and MCP `run_action` accept a `type: 'api'`
action whose `target` is a same-origin path, run their existing gates (the
sign-in floor, D4, the ADR-0126 §8 activation consult, the param contract,
the subject load) and forward the request in-process to the declared target
with the caller's credentials, the body assembled from the declaration by the
console's own rules, and the target's credential rotation relayed back (every
Set-Cookie line, set-auth-token, set-admin-session-token). An off-origin api
action stays browser-only: both doors refuse it, MCP does not list it, and the
activation write door refuses to flip it with the reason.

`HttpDispatcherResult.response.headers` accepts `string[]` (one header line
per element); the Hono adapter's route `res.header` appends an array instead
of comma-joining it.

Pinned over fakes at both doors (`domains/api-action-forward.test.ts`) and on
the real stack (`api-action-door-forward.dogfood.test.ts`: ban_user off → 409
and not banned, on → banned; unban_user; set_user_password; impersonate_user
on the cookie and bearer lanes; a member's 403 and the anonymous 401; the
off-origin flip and run refusals; the MCP type gate).

Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W
Co-authored-by: Claude <noreply@anthropic.com>
…n the route-envelope ledger

Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/hono, @objectstack/plugin-hono-server, @objectstack/runtime, touching 31 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/runtime/test-typecheck-debt.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0561a988cb0b8b73becf80bbac8b3a407ed16e7e.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/test-typecheck-debt.json) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 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 — 32 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 0561a988cb0b8b73becf80bbac8b3a407ed16e7e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f198861ec42e881c8ab582b07bf3ea36ea5a0ff5 — the merge of head a87722dd95a350b6ed0592c4698ebb23b6dee758 into base 0561a988cb0b8b73becf80bbac8b3a407ed16e7e, 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 f198861ec42e881c8ab582b07bf3ea36ea5a0ff5 && git checkout f198861ec42e881c8ab582b07bf3ea36ea5a0ff5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0561a988cb0b8b73becf80bbac8b3a407ed16e7e a87722dd95a350b6ed0592c4698ebb23b6dee758 && git checkout -B drift-repro 0561a988cb0b8b73becf80bbac8b3a407ed16e7e && git merge --no-ff a87722dd95a350b6ed0592c4698ebb23b6dee758

node scripts/docs-audit/affected-docs.mjs --json 0561a988cb0b8b73becf80bbac8b3a407ed16e7e

⚠️ 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 0561a988cb0b8b73becf80bbac8b3a407ed16e7e → 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

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head 75d7d9e · 2026-10-29T16:16Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 75d7d9e115f02073fd761a170cad7eece9367359
Local-runs: none
Scope: packages/runtime/src/action-execution.ts, packages/runtime/src/domains/actions.ts, packages/runtime/src/domains/mcp.ts, packages/runtime/src/http-dispatcher.ts, packages/plugins/plugin-hono-server/src/adapter.ts, scripts/check-route-envelope.mjs, packages/runtime/src/domains/api-action-forward.test.ts, packages/runtime/src/action-execution-destructive.test.ts, packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts + packages/runtime/src/index.ts (what @objectstack/runtime publishes), packages/runtime/src/dispatcher-plugin.ts (the response.headers consumer), packages/adapters/hono/src/index.ts (the second in-repo consumer), packages/spec/src/contracts/http-server.ts (IHttpResponse.header, getRawApp), packages/runtime/src/security/inbound-rate-limit.ts (the server.trustProxy rule), packages/types/src/in-process-session-read.ts (cookie-session admission), packages/mcp/src/mcp-http-tools.ts (run_action result rendering), ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx and ui/packages/app-shell/src/utils/resolvePageVarTokens.ts (the console's api body rules), docs/adr/0066-unified-authorization-model.md D4, docs/adr/0126-packaged-metadata-customization-model.md §4/§8, packages/spec/src/ui/action.zod.ts (requiredPermissions docblock), packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts (the untouched pin the diff flips), scripts/check-adr-0087-registration.mjs (breakingDeclaration), .changeset/27506-api-action-door-forward.md and content/docs/ui/actions.mdx (prose: ② and the contradiction check only). Face: narrowing (Clause-②: no (narrowing) on the PR body and the changeset). Cited code read on origin/main 144702ca5dd1 in a detached checkout; head lines from git show 75d7d9e1:path.

Check-runs on the head, read 2026-10-29T16:28Z (first read 2026-10-29T16:18Z): Test Core (3/8) failure (annotation: command (packages/runtime) pnpm run test exited (1)); Test Core (4/8) and (5/8) still in_progress; every other non-skipped check success (Lint & Repo Gates, TypeScript Type Check, Build Core, Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate, Test UI, the six Test Core shards that completed). The job log sits on a blob host this session's proxy refuses, so the failing case below is read off the tree, not the log.

① Derived judgments

Widenings the diff implies — each named right:

  • POST /actions/:object/:action and MCP run_action accept a type: 'api' action whose target starts with / (not //, not /\) and forward it in-process after the door's own gates (isHeadlessInvokableAction head 1013-1016, headlessActionTypeError head 1491; domains/actions.ts head 830-863; invokeBusinessAction head 3253-3283). Right — the ruling's letter C, step 2.
  • MCP list_actions lists such actions without a handler-registry hit (domains/mcp.ts head 837). Right.
  • HttpDispatcherResult.response.headers accepts string[] (http-dispatcher.ts head 160) and the hono plugin's route res.header appends an array one line per element (adapter.ts head 816-828). Right as a mechanism — IHttpResponse.header(name, value: string | string[]) was already the contract (packages/spec/src/contracts/http-server.ts:100), and the plugin's middleware-seam response at adapter.ts:1530 already appended arrays; the route seam was the one that did not.

Narrowings the diff implies — each named right, with one wrong carrier:

  • POST /actions/_activation/:object/:action refuses, 409, to flip a type: 'api' action whose target is not a same-origin path, in both directions, after the authority gates (domains/actions.ts head 239-244, step 3b after step 1's two authority refusals — the 409 discloses nothing to an ungranted caller). Before the diff that request answered 200 and wrote a ledger row. Right, and it is THE narrowing of a REST answer this PR makes: a row already written for an off-origin api action can no longer be flipped back on, which is the one thing a deployer could notice. The Clause-②: no (narrowing) arm is therefore right. Its rationale sentence on the PR body and the changeset ("an api action that reaches the doors now passes their gates … where the console's direct fetch passed none") names a widening of the doors (they answered 400 to the type before) and a comparison with a path this PR does not change (the console still fetches target directly until Console: send a same-origin type: 'api' action click through POST /actions/:object/:action instead of fetching its target directly (ruling on #27506, step 3 of 3) #27611); it does not name the 409. The arm stands; the sentence is corrected under ② (should-fix).
  • Both doors refuse an off-origin api action 400 with a new message naming the origin (head 1497-1509). Right — a 400 before, a 400 now; the message is the only change.
  • MCP run_action on a same-origin api action is no longer refused for its type; the system-object guard (isSystemObjectName, head 2686) still refuses every sys_* object ahead of it, so none of the 13 identity actions is reachable over MCP. Right as code; the ruling's sentence "list_actions and run_action expose them to agents with no further change" is not true for the actions the card is about — see ③.

Published surface:

  • @objectstack/runtime publishes only src/index.ts (package.json exports["."]), which re-exports nothing from action-execution.ts — the eleven new exports there (isSameOriginApiTarget, isSameOriginApiAction, offOriginApiActivationRefusal, API_FORWARD_MARK_HEADER, buildApiActionRequest, resolveInProcessRequest, apiActionForwardUnavailableError, apiActionForwardLoopError, forwardApiAction, apiActionResultOf, apiActionForwardRefusal and the three types) are package-internal. No new published symbol. isSameOriginApiAction (head 1075) has no caller in the tree — should-fix (dead export).
  • HttpDispatcherResult IS published (index.ts:212, export type { HttpProtocolContext, HttpDispatcherResult }), so widening response.headers from Record<string, string> to Record<string, string | string[]> changes a published type: for a producer it widens; for a consumer that reads the field (a host adapter) it is a breaking type change — a reader typed to string no longer compiles without a cast. The in-repo reader packages/runtime/src/dispatcher-plugin.ts:471-474 hands the value to IHttpResponse.header, which takes the array — fine. The second in-repo reader, the published @objectstack/hono adapter (packages/adapters/hono/src/index.ts:764 and :861), casts v as string and hands the array to Hono's c.header, which stringifies it comma-joined — the exact Set-Cookie defect this diff fixes in the plugin. On that host the forward itself answers 503 (no http.server service is registered there, so resolveInProcessRequest is null), so this producer cannot reach it today; but the published type now says arrays may arrive and that published consumer mishandles them. Should-fix: update that consumer in the same change, and name the type change in the BREAKING banner (②).
  • @objectstack/plugin-hono-server publishes adapter.ts wholesale (src/index.ts export * from './adapter'); the route res.header parameter type widening is the contract's own signature — no new symbol, no breaking read.

Declaration line: Clause-②: no (narrowing) — right; Metadata-protocol: none — right (no authorable key, value or meaning changes in packages/spec; the semantic change rides #27610). The BREAKING carriers: AGENTS.md's Post-Task Checklist step 3 says the (narrowing) arm "is BREAKING" and that a non-protocol breaking change "ships patch and keeps its BREAKING banner and ADR-0087 disposition"; precedent #26801 (the same no (narrowing) of a REST answer, patch, not-required (no-migration-prescription)) carried fix(plugin-security)!: in its title and a **BREAKING** accept-set narrowing … paragraph with Before / Now / Unchanged / Who sees it / The remedy in its changeset. This changeset carries the disposition marker but no **BREAKING** banner (zero occurrences), and the PR title carries no !. scripts/check-adr-0087-registration.mjs breakingDeclaration reads the arm alone as a breaking signal, so no gate reds — which is exactly why the rule text has to be applied by review. Ruling: this narrowing owes both. The banner is a must-fix on the declaration carriers (the rule says it is kept; it is the sentence an upgrading agent greps in CHANGELOG.md); the ! travels with it (the squash subject is the commit this gate's bang signal reads).

② Semver level

'@objectstack/runtime': patch, '@objectstack/plugin-hono-server': patch — right for the kind: Metadata-protocol: none takes patch, and the rule keeps patch for a non-protocol breaking change. No other published package moves: @objectstack/spec is untouched, @objectstack/mcp is untouched, scripts/ publishes nothing, the dogfood package is private.

What the changeset owes before it is right (all on the same file, one edit):

  1. Must-fix — a **BREAKING** paragraph (the fix(plugin-security)!: security explain refuses a userId that no sys_user record has with 404 RECORD_NOT_FOUND #26801 shape): Before (the activation write door recorded a flip for any declared action), Now (409 for a type: 'api' action whose target is not a same-origin path, both directions, no ledger row), Who sees it (a deployment that already switched such an action, or an app declaring one), The remedy (point the target at a same-origin path, or remove the action — the refusal's own sentence). Add the published-type change: HttpDispatcherResult.response.headers may now hold string[]; a host adapter that reads it hands each element to IHttpResponse.header (which takes the array) — the one-line fix.
  2. Should-fix — the Clause-②: rationale sentence names the 409 above (the narrowing) rather than the doors' new acceptance (a widening) — the arm is right, the reason is not.
  3. Should-fix — the ADR-0087 marker's why says "no … export … changes meaning; an app upgrades with no edit". A host adapter built against the published HttpDispatcherResult needs the edit above; not-required (no-migration-prescription) stays (the prescription is one line and lives in the banner, as fix(plugin-security)!: security explain refuses a userId that no sys_user record has with 404 RECORD_NOT_FOUND #26801 did), but the why must not deny the type change.

Prose check: content/docs/ui/actions.mdx now says a same-origin type: 'api' action is dispatched through the doors. On the tree this record reviews, docs/adr/0066-unified-authorization-model.md:74 (D4, 2026-07 scope note) and packages/spec/src/ui/action.zod.ts:1596-1606 (the requiredPermissions docblock and .describe(), which check:docs renders) say the opposite — "fetched by the browser directly and never passes through that route". The doc sentence is contradicted by the tree until #27610 lands; that is the landing order the ruling set, not a defect of this diff, and it is why this record does not authorise landing ahead of #27610 (③).

③ Boundary flags

Every flag in the dev's os-dev-report (6502065277) and the ruling's execution text, answered or escalated:

④ Design judgment

Judged against the ruling's scenario (one system for people and agents: a switched-off packaged api action refused at every door, an on api action invocable headlessly through the same doors) and against the code each hunk cites.

packages/runtime/src/domains/actions.ts — holds, with one must-fix elsewhere pinned here. The api arm (head 830-863) sits after the anonymous floor (427-460), the disabled-package refusal (548-556), the D4 gate (637-640), the ADR-0126 consult (655-664), the type check, the body/param contract (719-726), the subject load (734-737) and refuseDeniedSubjectLoad (head 823), and before the trusted-mode actionContext — so every gate the door already ran still runs first, and an api action never receives the system-elevated facade. The target's own gates run after the forward because the forward is an ordinary in-process request through app.request (the dogfood pins a member's 403 and an anonymous 401 at the door). The two relayed answers spread deps.success / deps.error and add headers (head 846-855), and the route-envelope ledger declares them (scripts/check-route-envelope.mjs handBuilt: 3, with the note the ledger requires; the lint gate is green). Step 3b of the activation write (head 239-244) is after both authority refusals — right. The untouched pin packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts:350-366 ("points a type: api action at the endpoint it actually dispatches on") asserts 400 for { type: 'api', target: '/api/v1/auth/organization/create-team' } through a fixture that resolves no http.server (lines 73-82); under this diff that call reaches the api arm and answers 503 (apiActionForwardUnavailableError), which is the packages/runtime red in Test Core (3/8). Must-fix: rewrite the pin to the contract this diff sets (the forward through an injector, and the 503 on a kernel with none) — never delete it (a test is deleted only when its PR names a duplicate pin at a layer at least as close). The report's "128/128" covered four files, not the package suite the shard runs.

packages/runtime/src/action-execution.ts — the design holds; three findings.

  • Same-origin test (isSameOriginApiTarget, head 1065-1072): a path starting with / whose second character is neither / nor \. Tried: https://same-host:other-port/… → refused as off-origin (conservative: such an action becomes browser-only and unswitchable; acceptable, the console's sameOriginOnly lane treats an absolute URL the same way); //host and /\host → refused; an encoded /%2F%2F… → stays in-process (app.request routes on the path; no origin escape); a ${param.*} or {field} value is percent-encoded before it lands in the path (head 1140-1141, 1207-1210) and the interpolated path is re-tested (head 1226), so a param cannot turn a same-origin template into //…; a target that is an action door → the forward carries x-objectstack-action-forward and both doors refuse a request bearing it (head 3253-3258; domains/actions.ts head 831-834), the MCP door included because its context.request.headers is the same lower-cased c.req.header() map (adapter.ts:728); a target on another door (/data/..., /automation/.../trigger) runs that door's own gates with the caller's own credentials — no weaker gate is reachable than by calling it directly. Holds.
  • Credentials forwarded (API_FORWARD_HEADER_ALLOWLIST, head 1105-1114): the caller's authorization, cookie, x-admin-session-token, x-api-key, tenant/environment ids, origin/referer/user-agent/accept-language, and the proxy provenance headers; nothing else crosses, host and content-length are the injector's, accept is forced. No header the caller does not already control is added except the loop mark. Holds — the forward carries exactly the admitted principal. Should-fix (security hardening, mechanism only): head 1401-1406 build the injected URL's origin from the caller-controlled x-forwarded-host / x-forwarded-proto unconditionally; the platform's one forwarded-header trust rule is server.trustProxy (packages/runtime/src/security/inbound-rate-limit.ts:130-141, resolveClientAddress), and no other site in packages/ reads x-forwarded-host. Read host only, or honour server.trustProxy. Detail in the seat's evidence file.
  • Relay back (head 1424-1433): set-auth-token, set-admin-session-token, and every Set-Cookie line as an array. On the REST door this is the ruling's own order and the hono plugin now emits one line per element — right. Must-fix (security, mechanism only): the MCP arm returns the same headers object inside the tool result (head 3280), and packages/mcp/src/mcp-http-tools.ts:1298-1306 renders it as JSON text; the MCP door admits cookie sessions (packages/types/src/in-process-session-read.ts:94-106 reads the cookie header for every in-process session read), so a cookie-lane caller receives HttpOnly session-cookie values inside a JSON body, which is what HttpOnly exists to prevent. The MCP transport has no cookie consumer; relay only the bearer-lane pair there. Detail in the seat's evidence file.
  • Body assembly (buildApiActionRequest, head 1184-1300) against the console's apiHandler (useConsoleActionRuntime.tsx:411-497): same order — {field} from the row, bodyShape.wrap, the outputs.* fold, recordIdParam seeded or refused by name (recordIdField is not harvested into the grid's $select — sys_session "Revoke Session" reports success and revokes nothing #8018), organizationId for an /api/v1/auth/ target, bodyExtra last, method, no body on GET/DELETE (the console sends no query string for GET either, so parity holds). Two divergences: the console injects organizationId when the value is falsy, the door only when undefined (negligible); should-fix — carriesPageToken (head 1154-1159) refuses any string containing {{, while the console's grammar is {{page.path}} (resolvePageVarTokens.ts:27,47), so a legitimate param value or bodyExtra string carrying {{ is refused 400 for a token it does not carry — match the grammar.
  • Error envelopes: a non-2xx answer rides the target's own status and code (head 1467-1480, standardErrorCodeForHttpStatus as the fallback); a 3xx is folded into that refusal shape with no Location (no packaged action targets a redirecting route — noted, not a finding); a non-JSON answer is a 200 with { raw } under a forced JSON accept — should-fix per ③; an empty body is null. The result unwraps data only when the body carries both success and data (head 1448-1452), one notch stricter than the console's 'data' in json — right (a bare better-auth body with a data key is not an envelope).
  • MCP type gate: isHeadlessInvokableAction admits a same-origin api target with or without an automation service — right; offOriginApiActivationRefusal (head 1089-1101) names "off-origin" for a target that is absent or not a string as well — the sentence is slightly wrong for a target-less declaration (nit).
  • isSameOriginApiAction (head 1075) — exported, never called: should-fix.

packages/runtime/src/domains/mcp.ts — holds. The listing skips the handler-registry check for type: 'api' exactly as for flow and the declarative update (head 837), after the sys_* fail-closed line and isHeadlessInvokableAction, before the ai.exposed gate; run_action is unchanged here and takes invokeBusinessAction's sequence (exposure → D4 → consult → params → confirmation → subject load → refuseDeniedSubjectLoad → api arm). The dogfood pins the gate order on the live transport.

packages/runtime/src/http-dispatcher.ts — holds as a mechanism; it is a published-type change (①, ②) and one published consumer mishandles it (①).

packages/plugins/plugin-hono-server/src/adapter.ts — holds. The route seam now matches the contract's signature and the plugin's own middleware seam (adapter.ts:1530-1533); c.header(name, v, { append: true }) is the one correct spelling for Set-Cookie; streamHeaders keeps a comma-joined copy for the streaming path, where no cookie travels (nit, not a finding). getRawApp() returns the Hono app (adapter.ts:1825), whose request runs the full middleware chain; readRemoteAddress is null-safe on an injected request (adapter.ts:181-190), so the rate limiter keys by principal, never by a fabricated address.

scripts/check-route-envelope.mjs — holds: handBuilt: 3 with the ledger's required note, matching the two relayed answers plus the 405; the gate that reads it is green on the head.

Tests — api-action-forward.test.ts pins each forwarded byte over fakes (both doors, the disabled 409 with the target never called, the loop refusal, the 503, the off-origin 400, the cookie array, the target's own refusal, the body rules); action-execution-destructive.test.ts flips the "gate 2 excludes" pin to "gate 2 admits" with the same-origin precondition asserted, and keeps the count of exclusion grounds a measurement; the dogfood file boots the real showcase with the admin plugin and the live MCP transport and pins the switch, the body, both credential lanes, the member 403, the anonymous 401, the off-origin refusal with no ledger row, and the MCP type gate. Right. What is missing is the one pin the diff did not look for: the untouched api-type dispatch test above.

Must-fix (each a FAIL on its own):

  1. Test Core (3/8) is red on the head — the untouched pin http-dispatcher.actions-type-dispatch.test.ts:350 now answers 503 where it asserts 400; rewrite it to the new contract.
  2. The MCP arm relays the cookie lane into the tool's JSON text (action-execution.ts head 3280 with 1430-1432; mcp-http-tools.ts:1298-1306) — relay only the bearer-lane pair over MCP.
  3. The BREAKING carriers: the changeset owes its **BREAKING** paragraph (the activation write door's 409, and the published HttpDispatcherResult.response.headers widening with its one-line fix), and the title its !, per the Post-Task Checklist rule and precedent fix(plugin-security)!: security explain refuses a userId that no sys_user record has with 404 RECORD_NOT_FOUND #26801.

Should-fix:
4. forwardApiAction builds the injected origin from caller-controlled forwarded headers without server.trustProxy (head 1401-1406).
5. packages/adapters/hono/src/index.ts:764, :861 cast the widened header value to string and comma-join an array.
6. carriesPageToken (head 1154) refuses any {{, not the {{page.path}} grammar.
7. A non-JSON target answer is relayed 200 { raw } under a forced JSON accept (head 1394, 1417) — refuse loudly or relay bytes, or amend #27611's body; one of the two in writing.
8. isSameOriginApiAction (head 1075) has no caller.
9. The Clause-②: rationale and the ADR-0087 why on the changeset (② items 2-3).
10. The sys_* MCP gap versus the ruling's sentence — a card or a written acceptance on #27506 (③).

Implemented-by: claude/project-thread-lzb7u9
Reviewed-by: subagent of session_01L6bhgXcN9HaZYSEUdJWEjw (domain:server#1 contract reviewer)

VERDICT: FAIL

Reviewed 2026-10-29T16:33Z. Landing stays behind #27610 whatever the next record says.

… for non-JSON, {{page.*}}-only refusal; adapters append array headers

Contract-review findings on the same-origin `type: 'api'` forward (#27506):

- The MCP `run_action` arm relays only the bearer-lane pair
  (`set-auth-token` / `set-admin-session-token`) into the tool result;
  no `Set-Cookie` line enters it. The MCP door admits cookie sessions and
  a tool result is JSON text whatever receives it can read, so an
  `HttpOnly` session cookie's value must not travel there. The REST door
  still relays every cookie line on the response.
- The forwarded URL is `http://<Host header><path>`, the URL the direct
  request has at this process. `X-Forwarded-Host` / `X-Forwarded-Proto`
  are no longer consulted for it (caller input unless `server.trustProxy`,
  which this seam cannot see); they still cross to the target as headers.
- A target that answers 2xx with a non-JSON body is refused 502 naming
  its content-type instead of relayed as `{ raw }` under 200.
- Only the console's `{{page.<path>}}` syntax is a browser-only body
  token; any other `{{…}}` is the author's literal text.
- A kernel with no in-process request path answers 503 addressed to the
  operator and no longer tells the caller to call the target directly.
- The unused `isSameOriginApiAction` export is removed.
- The published `@objectstack/hono` adapter's `toResponse` emits a
  `string[]` header value as one line per element on its `response` arm
  and both `stream` arms, as the Hono server plugin already does.
- The stale pin in `http-dispatcher.actions-type-dispatch.test.ts` that
  asserted the 400 "call the target yourself" answer is rewritten for the
  new contract: forward when the kernel can, 503 when it cannot.
- Changeset: BREAKING section for the activation write door's 409 on an
  off-origin api action and the widened
  `HttpDispatcherResult.response.headers` value type, with the one-line
  consumer fix; `@objectstack/hono` added to the bump.

Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head f72f7d0 · 2026-10-29T17:32Z

…ns; test-typecheck debt re-recorded downward

The two #27506 cases in http-dispatcher.actions-type-dispatch.test.ts read
`res.response.…` unnarrowed, which grew the ledgered TS18048 debt for the
file (24 recorded, 28 measured). They now bind the envelope once and refuse
loudly when it is absent, and the pin they replaced carried two of the
ledgered accesses, so the file's debt shrinks 24 -> 22;
`gen:test-typecheck-debt` re-recorded that number (no other entry moves).

Claude-Session: https://claude.ai/code/session_01L6bhgWdZYC1P9NBFo7ef6W
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract-review-claim: session_01L6bhgXcN9HaZYSEUdJWEjw · head a87722d · 2026-10-29T17:42Z

This branch has not been deployed

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

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants