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 7 commits into
mainfrom
claude/project-thread-lzb7u9
Draft

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

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

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. Two published runtime surfaces narrow for their consumers: POST /actions/_activation/:object/:action now refuses (409) to flip an off-origin type: 'api' action it accepted and recorded before, and the headers member of @objectstack/runtime's HttpDispatcherResult.response is widened from string values to string | string[], which a reader that assigned a value to a string no longer compiles against. An api action that reaches POST /actions/:object/:action or MCP run_action now passes the doors' gates (the sign-in floor, requiredPermissions, the activation switch, the param contract, the subject-row load) 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.

BREAKING at two published runtime surfaces (@objectstack/runtime).

  • The activation write door, POST /api/v1/actions/_activation/:object/:action. Before: flipping a type: 'api' action whose target is on another origin was accepted and recorded as a ledger row, which nothing ever consulted — the switch read as set and did nothing. Now: that request is refused 409 (error.code RESOURCE_CONFLICT) with a message naming the origin and saying why (the browser calls that origin directly and this server never sees the request), and no row is written. Same-origin api actions, and every other action type, flip exactly as before. Who sees it: a Setup caller or script that switched an off-origin api action off and read the 2xx as "stopped". The remedy: none at the door — move the action's target onto this origin (a path starting with /) to make it switchable, or remove the action from the app; a script that treated the 2xx as success now reads the 409 reason.
  • The type HttpDispatcherResult.response.headers (packages/runtime/src/http-dispatcher.ts). Before: Record<string, string>. Now: Record<string, string | string[]>; an array is one header line per element, and the /actions door emits one for Set-Cookie. Who sees it: an adapter or host outside this repository that reads the member and hands each value to a string-typed header setter (error TS2345). The remedy, one line: branch on Array.isArray(value) and emit each element as its own header line (Hono: c.header(name, line, { append: true }); Fetch Headers: headers.append(name, line)); joining the array with , is NOT equivalent for Set-Cookie. The two in-repo adapters (@objectstack/plugin-hono-server, @objectstack/hono) ship that change in this release.

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 is relayed: on REST every Set-Cookie line plus set-auth-token / set-admin-session-token, so impersonate_user works through the door on the cookie lane and the bearer lane; over MCP only the bearer pair (set-auth-token / set-admin-session-token) — a Set-Cookie value never enters a tool result. The forward URL is built from the incoming Host alone (x-forwarded-* cross only as headers, for the target's own trustProxy reading), and a 2xx target answer that is not JSON is refused 502 (EXTERNAL_SERVICE_ERROR) naming its content-type.
  • 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 console's own {{page.*}} grammar; any other {{…}} passes through literally) 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 both in-repo Hono adapters append an array instead of comma-joining it (@objectstack/plugin-hono-server's route res.header, and @objectstack/hono's toResponse on its response, event-stream and raw-stream arms) — 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), plugin-hono-server/src/adapter.ts (the array append and the Host backfill) and packages/adapters/hono/src/index.ts with its new test hono-response-header-array.test.ts — all 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. The pre-existing pin http-dispatcher.actions-type-dispatch.test.ts is rewritten to the new contract (forward through the injector; 503 without http.server), and packages/runtime/test-typecheck-debt.json shrinks that file's TS18048 row 24 → 22.

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.

维护者速读(席位终稿)

席位意见:契约复核两轮(75d7d9e1 FAIL → a87722d PASS,记录 6504462099),三条必改与六条应改均在树上修复并有钉;建议在 #27610 合并后落地。


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 6bc764332aecfb63345e2abd5d2a3c1b550e114f.

⛔ 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 6bc764332aecfb63345e2abd5d2a3c1b550e114f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6bc764332aecfb63345e2abd5d2a3c1b550e114f

⚠️ 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 6bc764332aecfb63345e2abd5d2a3c1b550e114f → 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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a87722dd95a350b6ed0592c4698ebb23b6dee758
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, packages/adapters/hono/src/index.ts, scripts/check-route-envelope.mjs, packages/runtime/src/domains/api-action-forward.test.ts, packages/runtime/src/http-dispatcher.actions-type-dispatch.test.ts, packages/runtime/src/action-execution-destructive.test.ts, packages/adapters/hono/src/hono-response-header-array.test.ts, packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts + packages/spec/src/api/errors.zod.ts (the 409 and 502 catalog rows), packages/runtime/src/index.ts, packages/runtime/src/dispatcher-plugin.ts, packages/mcp/src/mcp-http-tools.ts (the one caller that renders invokeBusinessAction's result), packages/types/src/in-process-session-read.ts, ui/packages/app-shell/src/utils/resolvePageVarTokens.ts, packages/plugins/plugin-hono-server/src/adapter.ts (the Host backfill at the request seam), packages/runtime/test-typecheck-debt.json (the per-file shrink-only ledger), .changeset/27506-api-action-door-forward.md (② and the banner). Face: narrowing. Delta review against record 6502515189 (FAIL on 75d7d9e1) of the two commits since: f72f7d04 (git diff 75d7d9e1..f72f7d04: 6 files, +382/−50 — the fixes) and a87722dd (git diff f72f7d04..a87722dd: 2 files, +11/−7 — the test-typecheck repair); cited code read on origin/main 144702ca5dd1, head lines from git show a87722dd:path (the source files are byte-identical between the two heads).

Check-runs on f72f7d04 (the intermediate head), last read 2026-10-29T17:43Z: Type Check · workspace failure and with it TypeScript Type Check (the TS18048 'res.response' is possibly 'undefined' count for http-dispatcher.actions-type-dispatch.test.ts rose above its ledger row: 24 reads at 75d7d9e1, 28 at f72f7d04, row 24 — the rewritten pins read the optional member four more times); Build Core, Build Docs, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, PR-body guards, Test UI, every Lint job that had finished — success; the eight Test Core shards were cancelled by the next push and never answered. Check-runs on a87722dd, first read 2026-10-29T17:42Z: Governed Surface Queue Guard, PR-body guards, Check PR Size, Check Documentation Links — success; Check Changeset, Spec property liveness, every Lint and Type Check job — in_progress; Test Core not yet planned. Re-read 2026-10-29T18:00Z: every non-skipped check success — Lint & Repo Gates, TypeScript Type Check (Type Check · workspace included: the f72f7d04 red is cleared), Build Core, Governed Surface Queue Guard, Check Changeset, Dogfood Regression Gate, Test UI, and all eight Test Core shards; the one context not yet concluded is the Test Core roll-up, queued behind its eight green shards. Nothing read red on this head.

① Derived judgments

The previous record's findings, each against the delta:

  1. Test Core red / the stale pin — fixed. http-dispatcher.actions-type-dispatch.test.ts:364-425 rewrites the old 400 pin into two: the forward through a fixture rawRequest (URL http://localhost/api/v1/auth/organization/create-team, the registry never called, the target's data unwrapped once) and the 503 on a kernel with no http.server (target named, getRawApp named, no "not found", no "call it yourself"). Rewritten, not deleted; no other test in packages/runtime asserts the old refusal (http-dispatcher.test.ts:4094 is a comment and stays true). The dev's whole-package run (475 files / 7789 tests) is a reading of theirs; the gate's own reading is the check-run line above. The second commit, a87722dd, repairs what the first broke in the type-check layer: the two new pins bound res.response through const answer = res.response; if (!answer) throw … (head 402-406, 422-427) — a real narrowing, not a ! assertion or an any — and the file's TS18048 row in packages/runtime/test-typecheck-debt.json moves 24 → 22 (head line 46). Measured: res.response. reads in that file are 24 / 28 / 22 at the three heads, so the row was exact before, red at f72f7d04, and shrinks at a87722dd — the shrink-only direction AGENTS.md's ratchet demands (gen:test-typecheck-debt re-recorded, not hand-raised). Only those two files move between f72f7d04 and a87722dd; every source file, the changeset and the other tests are byte-identical, so every judgment below made on f72f7d04 holds on a87722dd.
  2. MCP relay of the cookie lane — fixed. mcpRelayableForwardHeaders (head 1387-1394) projects the relayed map onto API_FORWARD_RELAYED_RESPONSE_HEADERS (the bearer pair) and admits only string values; the MCP arm spreads that projection (head 3344). Tried to find another path that carries a Set-Cookie line into a tool result: invokeBusinessAction has one caller outside its own file (domains/mcp.ts:869, the bridge run_action and nothing else); the non-2xx path throws an Error carrying code/status and no headers; the new 502 path answers headers: {}; the target's JSON body is the target's own, not the door's relay. None left. The REST door still relays every Set-Cookie line on the response (domains/actions.ts head 844-855), where only the browser sees it — right.
  3. The BREAKING carriers — fixed. The changeset's summary and the PR title read fix(runtime)!:; a **BREAKING** at two published runtime surfaces section carries Before / Now / Who sees it / The remedy for the activation write door's 409 and for the HttpDispatcherResult.response.headers widening, with the one-line adapter fix. The 409's code is named RESOURCE_CONFLICT, which is the catalog's row for 409 (errors.zod.ts:40) and what deps.error(…, 409) derives — right. Bump set: @objectstack/hono: patch added — right, that published package's source changed; @objectstack/runtime and @objectstack/plugin-hono-server stay patch (non-protocol breaking change keeps patch). Metadata-protocol: none unchanged — right.
  4. Forwarded-host trust — fixed. The injected URL is http:// + the incoming Host + path (head 1453-1454); x-forwarded-host / x-forwarded-proto are no longer read for it and still cross as headers for the target's own server.trustProxy rule (allowlist comment, head 1106-1114). Tried: a missing Host — the hono plugin backfills rawHeaders.host from c.req.url at the request seam (adapter.ts:728-733), and the fallback is localhost; an unparseable or mismatched Host never reaches this door through the real transport (@hono/node-server@2.1.1 answers 400 "Invalid host header" ahead of the app, index.mjs:175 and :863), and an injected request built from it would throw inside the door's one try and answer as a thrown fault, not a forward; app.request(url) routes on the path, so the forwarded x-forwarded-* headers cannot change request.url at the target (pinned: api-action-forward.test.ts "builds the forwarded URL from Host alone"). Scheme fixed at http is what the direct request has at this process (TLS terminates upstream) — the dogfood file's cookie and bearer lanes against real better-auth are the proof that its origin checks hold on it.
  5. @objectstack/hono header arrays — fixed. setResponseHeader (head 767-770) appends each element on the response arm (776) and the raw-stream arm (876); the event-stream arm appends on its own Headers (849-852). All three arms of toResponse that emit dispatcher headers are covered; the fourth header site (1018) is the better-auth passthrough, not a dispatcher result. The new hono-response-header-array.test.ts pins two Set-Cookie lines on each arm via getSetCookie, and a string value unchanged.
  6. Page-token grammar — fixed. PAGE_VAR_TOKEN_RE (head 1160) is the console's embedded regex verbatim (resolvePageVarTokens.ts:47; the whole-value form at :27 is the same token anchored), whitespace inside the braces included; {{greeting}}, see {{docs}} pass as literal text, {{ page.x }} is refused — pinned both ways.
  7. Non-JSON 2xx — fixed, and the envelope is right. A 2xx whose body does not parse as JSON becomes the door's own 502 before relay (head 1469-1481), code derived from the status: standardErrorCodeForHttpStatus(502) is the catalog's EXTERNAL_SERVICE_ERROR row ("external service errors (502/503)", errors.zod.ts:45,194) — the right name for "the upstream target answered something this gateway cannot carry", served through deps.error on REST and as a thrown code/status over MCP; the message names the content-type. Tried the legitimate non-JSON answers: a 204 or any empty body is null data and relays (the text.length === 0 arm keeps isJson true) — right; a JSON body under a non-JSON content-type still parses and relays — lenient, right; a non-2xx non-JSON answer keeps { raw } for the refusal — right. Nit (no fix owed): ApiActionForwardAnswer.body's docblock says the refusal "can quote" that raw text, but apiActionForwardRefusal reads only error.message / message and falls back to "answered HTTP N" — the raw text is never quoted. A 502 drops the relayed headers (headers: {}) — right, a refusal rotates nothing.
  8. Dead export — fixed. isSameOriginApiAction deleted; no reference remains in the tree.
  9. Changeset rationale and ADR-0087 why — fixed. The Clause-②: line now names the 409 and the type widening ahead of the doors' new acceptance, with no (narrowing) first after the colon; the marker's why names both narrowings and why each not-required category that could apply does not. Check Changeset is green on the head; the dev reads check-adr-0087-registration as BREAKING + bang + clause-②-narrowing with the disposition accepted (the Lint job carrying it is in the check-run line).
  10. sys_* over MCP — answered. The report's carrier is now "seat — TO FILE as a card"; the seat owes that card. Not a change to the tree.

No new accept-set or published-surface change rides the delta beyond those: apiActionNonJsonAnswerError and mcpRelayableForwardHeaders are new exports of action-execution.ts, which @objectstack/runtime does not publish.

② Semver level

@objectstack/runtime: patch, @objectstack/plugin-hono-server: patch, @objectstack/hono: patch — right for the kind (Metadata-protocol: none; a non-protocol breaking change keeps patch and its banner). The banner, the !, the disposition and the declaration lines all stand on the changeset. The pr_body_patch the seat will apply at ACCEPT (report 6503811129) carries the same Clause-②: line and the same BREAKING section as the changeset, byte for byte — fit to paste. Three sentences elsewhere in the PR body become stale the moment it is applied and should be patched in the same write: the "What changed" bullet still says every Set-Cookie line is relayed (now REST only; over MCP the bearer pair) and lacks the 502 and the Host-only URL; the "File surface beyond the claim's three" sentence omits packages/adapters/hono/src/index.ts, its new test file and the rewritten http-dispatcher.actions-type-dispatch.test.ts; the last bullet still credits only the server plugin's res.header with the array append. Body text, not tree — should-fix on the ACCEPT write, no bearing on this verdict.

③ Boundary flags

④ Design judgment

Each delta hunk against the code it cites and the ruling's scenario:

  • action-execution.ts — the forward now has one URL rule (the request's own Host), one relay rule per door (REST: headers on the response; MCP: the bearer pair in the result), one answer rule (JSON or the door's own 502), one token rule (the console's grammar). Each is pinned on both sides in api-action-forward.test.ts. The gate order on both doors is untouched. Holds.
  • adapters/hono/src/index.ts — three arms, one helper, one Headers loop; strings behave as before. Holds, with its own test.
  • http-dispatcher.actions-type-dispatch.test.ts — the fixture grows an optional injector and the pin now states the contract (forward, else 503 addressed to the operator); a87722dd narrows the optional envelope the way http-dispatcher.keys.test.ts's responseOf() helper already does, and the debt ledger follows the measurement downward. Holds.
  • The changeset — the carriers a breaking narrowing owes are all present and name the right surfaces, codes and remedies. Holds.

No must-fix remains from the previous record and the two commits introduce none. Should-fix, none on the tree: the three stale PR-body sentences named under ② (the seat's ACCEPT write), and the sys_* card the seat owes (③).

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

VERDICT: PASS

Reviewed 2026-10-29T18:01Z. Landing stays behind #27610.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

维护者速读(席位终稿)

  • 改了什么:同源 type: 'api' 动作改为经 POST /actions 门和 MCP run_action 派发:先过完现有各闸(登录、requiredPermissions、打包开关、参数、记录加载),再在进程内转发到声明的 target。外站 api 动作两门都拒,写入门也拒绝切换(409)。响应头通道支持多值。
  • 为什么改:Packaged action activation: switching off an api-type packaged action (Ban User) does not stop it, because the activation consult runs only at action dispatch #27506。关掉「封禁用户」后照样能封禁,因为 api 动作从不经过开关判定。裁决 C(6499781492)要求人和 agent 走同一扇门。
  • 风险与回滚:破坏性两处:外站 api 动作的切换改为 409;已发布类型 HttpDispatcherResult.response.headers 放宽为 string | string[]。changeset 与 PR 正文都带 BREAKING 段和一行修法。MCP 只回传 bearer 令牌对,Set-Cookie 不进工具结果。回滚就是 revert 本 PR,无数据迁移。
  • 先看:packages/runtime/src/action-execution.ts 的 buildApiActionRequest / forwardApiAction / mcpRelayableForwardHeaders,以及 domains/actions.ts 的 api 分支和写入门 3b。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts src/http-dispatcher.actions-type-dispatch.test.ts。
  • 需要你做的:先合 docs(adr): ADR-0066 D4 and ADR-0126 §8 — same-origin type: 'api' actions are dispatched by the platform action door (ruling on #27506, step 1 of 3) #27610(Tier H,ADR 文本)。它合并后,席位把本 PR 转 ready 并开自动合并。

席位意见:接受,落地排在 #27610 之后。契约复核两轮:75d7d9e1 FAIL(6502515189),a87722dd PASS(6504462099)。三条必改和六条应改都已在树上修复,每条都有测试钉住。

席位核对 — ACCEPT · domain:server#1 · head a87722d · 2026-10-29T18:05Z

…osure policy + troubleshooting row, actions-as-tools dispatch table)

Three sentences that #27506 made stale, aligned with content/docs/ui/actions.mdx:
the headless-callable set is script, flow and a same-origin `type: 'api'`
action (dispatched in-process through the action door by run_action); an
api action whose target is on another origin stays browser-only and is not
listed; sys_* objects stay held back for every type; a credential rotation
reaches an MCP tool result as the bearer-lane headers only, never Set-Cookie.
Prose only — no code, test or changeset semantics move.

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

Regen-provenance: 6504462099 · a87722dd95a350b6ed0592c4698ebb23b6dee758 → 533f8da14dabc67b793283120aaa297e10b753bf · git diff --name-only a87722dd95a350b6ed0592c4698ebb23b6dee758 533f8da14dabc67b793283120aaa297e10b753bf | grep -v -E "^(content/docs/|\.changeset/|apps/docs/)" → (empty)

Prose arm: the head moved by one docs commit (content/docs/ai/connect-mcp.mdx, content/docs/ai/actions-as-tools.mdx) at the reviewer's request on #27664 (6505129020); contract review PASS 6504462099 keeps governing. The seat reads the two pages against the diff in the refreshed ACCEPT.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw

维护者速读(席位终稿)

  • 改了什么:同源 type: 'api' 动作改为经 POST /actions 门和 MCP run_action 派发:先过完现有各闸(登录、requiredPermissions、打包开关、参数、记录加载),再在进程内转发到声明的 target。外站 api 动作两门都拒,写入门也拒绝切换(409)。响应头通道支持多值。
  • 为什么改:Packaged action activation: switching off an api-type packaged action (Ban User) does not stop it, because the activation consult runs only at action dispatch #27506。关掉「封禁用户」后照样能封禁,因为 api 动作从不经过开关判定。裁决 C(6499781492)要求人和 agent 走同一扇门。
  • 风险与回滚:破坏性两处:外站 api 动作的切换改为 409;已发布类型 HttpDispatcherResult.response.headers 放宽为 string | string[]。changeset 与 PR 正文都带 BREAKING 段和一行修法。MCP 只回传 bearer 令牌对,Set-Cookie 不进工具结果。回滚就是 revert 本 PR,无数据迁移。
  • 先看:packages/runtime/src/action-execution.ts 的 buildApiActionRequest / forwardApiAction / mcpRelayableForwardHeaders,以及 domains/actions.ts 的 api 分支和写入门 3b。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts src/http-dispatcher.actions-type-dispatch.test.ts。
  • 需要你做的:先合 docs(adr): ADR-0066 D4 and ADR-0126 §8 — same-origin type: 'api' actions are dispatched by the platform action door (ruling on #27506, step 1 of 3) #27610(Tier H,ADR 文本)。它合并后,席位把本 PR 转 ready 并开自动合并。

席位意见:接受,落地排在 #27610 之后。契约复核两轮:75d7d9e1 FAIL(6502515189),a87722dd PASS(6504462099)。三条必改和六条应改都已在树上修复,每条都有测试钉住。

席位核对 — ACCEPT (refresh on the docs head; supersedes 6504559626) · domain:server#1 · head 533f8da · 2026-10-29T18:57Z

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