Skip to content

fix(runtime): the keys and actions domains answer their 405 with an Allow header naming POST - #24227

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24023-keys-actions-405-allow
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24023-keys-actions-405-allow

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24023

Clause-②: no

Basis for the Clause-② line: no request that was accepted becomes refused, and none that was refused becomes accepted. A refusal that already answered 405 METHOD_NOT_ALLOWED now also carries an Allow: POST header. Its status, body and error.code do not change.

What changed

  • packages/runtime/src/domains/keys.ts and packages/runtime/src/domains/actions.ts. The 405 for every verb but POST was deps.error('Method not allowed', 405). deps.error returns { status, body } and nothing else. That was read off its definition (HttpDispatcher.error, which calls apiErrorResponse in error-envelope.ts), not off the comment in meta.ts. Each site now builds its response by hand, in the shape mcp.ts, meta.ts and ai.ts already use: status: 405, headers: { Allow: 'POST' }, and the body through buildApiError({ message, httpStatus: 405 }). So the code stays derived (METHOD_NOT_ALLOWED) and is not spelled. The message and the body are byte-identical to before. The anonymous-baseline statements stay the first statement of both handlers, above the 405.
  • Why POST. Each domain serves POST alone, on every path it claims. handleKeysRequest never reads its path. handleActionsRequest checks the method before it parses the path, and its activation door (isActionActivationWrite) is POST too. Each value is one named constant (KEYS_ALLOWED_METHODS, ACTIONS_ALLOWED_METHODS).
  • scripts/check-route-envelope.mjs: the DISPATCHER_DOMAINS ledger now counts keys.ts 1 to 2 hand-built responses and actions.ts 0 to 1. Both are kind 1 (enveloped, built by hand only for the header), each with a note. pnpm check:route-envelope went red without this entry, and its failure text asks for it ("raise the count and say which in note"). The meta.ts and ai.ts 405s raised the same ledger. This file is outside the claim's listed file surface. It is the declaration the gate requires for this exact change, and the report says so too.
  • New pin: packages/runtime/src/domains/keys-actions-method-not-allowed-allow.test.ts.
  • .changeset/24023-keys-actions-405-allow.md: @objectstack/runtime patch.
  • No shared helper. meta.ts is held by another lane, so a helper could not replace all five hand-built 405s in this PR. The dispatch rule allows a helper only if it replaces every one of them.

The sweep (Done when, third line)

git grep -n 405 -- packages/runtime/src/domains ':!*.test.ts' at base aa31569b2 finds exactly two deps.error(…, 405) sites, keys.ts:112 and actions.ts:446. The other three 405 producers already send Allow: mcp.ts:183, meta.ts:1583 and ai.ts:304. The same search at head 71613b4ef finds zero deps.error(…, 405) sites and five status: 405 literals, and each of the five has a headers: { Allow: … } line under it. The deps.error calls that take their status from a variable were checked too. No producer that feeds them has a 405: the only other non-comment 405 literals in packages/**/src are the data door's exposure refusal (Acceptance notes) and spec constants.

Measured on both hosts (H4)

The build is head e614b2adb, driven in process. A LiteKernel carries a fake auth service that answers a session only when the request has a test header. createHonoApp({ kernel, prefix: '/api/v1' }) is driven through app.request. The standalone server is HonoServerPlugin plus createDispatcherPlugin over a real socket and fetch. Both were run with CORS left at its default and with CORS off.

request createHonoApp standalone server
signed-in GET or DELETE /api/v1/keys 405, Allow: POST, domain envelope (httpStatus: 405) 405, Allow: POST, domain envelope
signed-in GET or PUT /api/v1/actions/task/close 405, Allow: POST, domain envelope 405, Allow: POST, the adapter's envelope (error.details): only POST is mounted, so the domain is never reached
anonymous GET /api/v1/keys 401 UNAUTHENTICATED, no Allow 401 UNAUTHENTICATED, no Allow
anonymous GET /api/v1/actions/task/close 401 UNAUTHENTICATED, no Allow 405, Allow: POST, the adapter's (see Acceptance notes)
OPTIONS on either path, CORS default 204 from CORS 204 from CORS

Neither host drops a domain's headers. createHonoApp's toResponse writes result.response.headers, and the standalone sendResultBase writes them after the security headers. So the producer was the domain, as the card says, and there was nothing to fix in domain:cli's host code. The base answer (no Allow on either host's domain 405) is the filer's reading and PR #24009's. On this branch it is reproduced by the ablation below.

Tests

  • New pin, 7 cases. On dispatch(), the call both hosts make, per domain: a signed-in GET gets 405, headers.Allow === 'POST', error.code METHOD_NOT_ALLOWED, error.httpStatus 405, and the body parsed against the spec's BaseResponseSchema / ApiErrorSchema. An anonymous GET gets 401 UNAUTHENTICATED with no Allow. On the standalone socket: the keys 405 with allow: POST on the wire and the domain's envelope, the anonymous keys 401 with no allow, and the actions 405 with allow: POST. That last answer is the adapter's and passes at base too. It is kept so that "both hosts" is a statement about the wire for both domains.
  • Ablation (one run, not kept). The fix was committed first. node scripts/ablation-replace.mjs, nested, put both sites back to today's return { handled: true, response: deps.error('Method not allowed', 405) };. Evidence: keys anchor 1 to 0, blob f6d9a5547690 to 0365f3e2c755; actions anchor 1 to 0, blob e15f8b9fff19 to bda747ac32f9. The pin then gave Tests 3 failed | 4 passed (7). The 3 failures are the dispatch-level keys and actions Allow rows and the socket keys row, each expected undefined to be 'POST' or expected null to be 'POST'. The anonymous rows and the adapter row stayed green. Restore: blob equals HEAD for both files, and git diff HEAD is empty for both. The pin imports ../http-dispatcher.js and ../dispatcher-plugin.js from source, so no dist/ of the mutated package sits on its resolution path.
  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 at 3fbad0f81: Test Files 410 passed (410), Tests 6287 passed | 19 skipped (6306). packages/runtime has no byte change from 3fbad0f81 to the head 71613b4ef.
  • pnpm --filter @objectstack/runtime typecheck: exit 0. The test-layer program (tsconfig.test.json) lists the new file (--listFiles: 1 hit among 413 test files), and check:test-typecheck holds its ledger unchanged.
  • pnpm exec eslint --no-inline-config over the four changed code files: exit 0.
  • Neighbours run with the pin, all green: keys-every-verb.hono.integration.test.ts, error-envelope.conformance.test.ts (its handleActions(… 'GET' …) 405 case) and domains/anonymous-gate-actions-automation.test.ts.
  • The repo vitest project (test:repo) was not run locally. This diff touches none of its files, and CI's Test Core runs it.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at head 71613b4ef, after the merge of origin/main, derived 84 commands. Each was run with its exit code captured before any pipe. --ran reconciles: 84 derived, 83 run, 1 NOT-MEASURED, 0 UNRUN. The 83 that ran all exit 0.

  • NOT MEASURED: pnpm check:dual-build-cjs-loads. Reason: PREREQUISITE NOT MET (exit 3). It reads the built dist/ of every package, 38 of them have none in this worktree, and the whole repository was not built locally. This diff changes no exports, no package.json and no build config. CI's Lint & Repo Gates runs it.
  • pnpm check:route-envelope was red on the first run, at 3fbad0f81, for the two hand-built 405s. The ledger entry above turned it green, and it is green in the final run.
  • Not run: node scripts/check-platform-checklist.mjs, because no exported symbol was renamed or removed. The local-only families check:dev-prereqs and check:skill-frame-freshness were not run either, because nothing in packages/spec or the skill frame was touched.
  • CI check-runs were not concluded when this PR was opened.

Acceptance notes

  • Where the card's first line and the anonymous rule meet. /actions passes the anonymous decision method (shouldDenyAnonymous lets OPTIONS through for CORS preflight). So an anonymous OPTIONS reaches the actions 405, and with this change that answer carries Allow: POST. Measured: this happens only on a createHonoApp host with CORS off. With CORS on (the default on both hosts) every OPTIONS is answered 204 before routing. The standalone server with CORS off already answers that same request 405 with Allow: POST, from the adapter. Holding Allow back from that one caller would leave a 405 without Allow, which is what the card's first "Done when" line forbids. /keys decides an anonymous caller without the method, so its anonymous OPTIONS is 401. This is in the report's open_questions for the seat to rule on if it reads the rule otherwise.
  • domains/mcp.ts at about :174–:179: the comment still says the 405s in actions.ts and keys.ts "go through deps.error", which this PR makes false. The fix is one comment edit, and mcp.ts is outside the claim's file surface. Carrier: none.
  • error-envelope.conformance.test.ts does not list domains/keys.ts, domains/actions.ts (nor domains/meta.ts) in its source-scan MODULES. So their hand-built bodies are held by this pin's envelope parse and by check:route-envelope's ledger, not by that scan. Carrier: none.
  • The data door's exposure refusal is a different shape and is outside this card's deps.error(…, 405) search. That refusal is 405 OBJECT_API_METHOD_NOT_ALLOWED, thrown in action-execution.ts and answered through deps.errorFromThrown in domains/data.ts; RestServer's twin is the same. It carries no Allow, and its allowed field lists API operations, not HTTP methods. Read from source, not measured on the wire. Carrier: none.
  • handleKeysRequest never reads its path, so on a createHonoApp host /api/v1/keys/ANYTHING reaches the domain, and Allow: POST is accurate there. The standalone server answers 404 for any /keys sub-path. Read from source, not measured. Carrier: none.
  • origin/main gained one commit (b9ac35cbf, CI workflows and scripts only) while this branch was open. It was merged before the final gate run so that the gate list is read from the current workflows. The merge touched no package source, so the earlier package builds stand.

Generated by Claude Code

…llow header naming POST

RFC 9110 requires an Allow header on a 405. Both domains built theirs
through deps.error, which carries no headers. They now hand-roll the
response the way the mcp, meta and ai domains do, with the body still
built by buildApiError so the code stays derived from the status. The
anonymous baseline stays each handler's first statement.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…llow

Both domains now hand-roll their 405 so it can carry the Allow header
deps.error cannot. The ledger names each as kind 1 (enveloped, built by
hand only for the header), as it already does for mcp, meta and ai.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 5 documentable anchor(s).

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

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

Coarse fallback — 26 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 b9ac35cbfe60afcea88947ed4eaf8c50e3efc214 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json b9ac35cbfe60afcea88947ed4eaf8c50e3efc214

⚠️ 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 b9ac35cbfe60afcea88947ed4eaf8c50e3efc214 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ying Allow

The keys and actions 405s now hand-roll their response with an Allow
header and a buildApiError body, so the comment that said they went
through deps.error was false. Comment-only.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · PR #24227 at 761b73cbd (#24023)

domain:cli seat 2 · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-17T23:06Z · holder of claim 6241519238. The dev's reports are comments 6242659006 and 6242821438 (patch round) on #24023, and the seat reviewed them against this PR, not the reports' own account.

  1. Shape. Draft → main. The first line is Fixes #24023, the only closing keyword. Clause-②: no is line-initial, and the assignee is marchtian. 6 files:
    • keys.ts, actions.ts;
    • one new pin;
    • one changeset;
    • two files outside the claimed surface, both accepted here:
      • scripts/check-route-envelope.mjs: the DISPATCHER_DOMAINS ledger declares the two new hand-built 405s, as its own failure text asks. Declared on [PM seat] domain:spec — ⏳ vacant #6017 (6242749081).
      • domains/mcp.ts, comment only (seat patch round 1): it stopped telling the next reader that these two 405s go through deps.error.
  2. The fix, read on the diff.
    • Both 405s are hand-built in the shape mcp.ts, meta.ts and ai.ts already use: status: 405, headers: { Allow: 'POST' }, and the body through buildApiError, so the code stays derived (METHOD_NOT_ALLOWED).
    • The anonymous baseline is still the first statement in both handlers.
    • Zero deps.error(…, 405) sites remain under packages/runtime/src/domains (the card's third "Done when" line).
    • 761b73cbd differs from 71613b4ef by 4 comment lines in mcp.ts, read by the seat.
  3. Evidence.
    • The pin has 7 cases on both hosts (dispatch() and a real socket). Ablating both sites reddens exactly the 3 Allow rows, and the restore is proven by blob.
    • The full runtime local suite: 410 files, 6287 passed.
    • At 71613b4ef: 84 derived, 83 run; check:dual-build-cjs-loads NOT MEASURED (it needs every package built); CI runs it.
    • At 761b73cbd: typecheck, ESLint on mcp.ts, and check:route-envelope all exit 0.
    • Landing waits on every check of 761b73cbd being green or a roster skip.
  4. Open question, seat ruling A (keep as shipped). An anonymous OPTIONS reaching the actions 405 with Allow: POST is the anonymous contract's own OPTIONS admission (CORS preflight). It is not a leak introduced by this PR. The order's clause was that the anonymous baseline stays first, and it does. Seen on a createHonoApp host with CORS off; the standalone adapter already answers that request the same way.
  5. Clause-②: no, read. A response header is added to an existing refusal. The status, body and code are unchanged. No accept set or export moves, and no contract-review face is touched.
    Changeset, read sentence by sentence:
    • "Both domains serve one method, POST, on every path they claim" matches both constants.
    • The two host bullets match the dev's two-host measurement: the standalone /actions non-POST is the adapter's own answer.
    • "Each domain still decides an anonymous caller before it looks at the method … no Allow header" holds for every verb the anonymous decision refuses.
  6. Out of scope:
  7. Landing. Ordinary queue landing once every check on 761b73cbd is green or a roster skip. Fixes #24023 closes the card, and the seat strips pm:dispatched in the same act.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 23:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 23:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 6e43b7b Oct 17, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24023-keys-actions-405-allow branch October 17, 2026 23:50
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/m tests tooling

Projects

None yet

2 participants