Skip to content

feat(core,runtime): the inbound automation hook through the dispatcher — one parameterised POST allow-list row, honoured by the /automation floor, forwarded to the trigger-api verifier - #22864

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22773-automation-hooks-domain
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22773-automation-hooks-domain

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22773
Clause-②: yes

Segment 2 of the trigger-api half of ruling A on #22757 (director record 6105447950, maintainer 「同意」): on the hosted shape, a kernel with no http.server, the inbound hook POST /api/v1/automation/hooks/:flowName/:hookId now admits an anonymous sender through to the trigger's own HMAC verifier, as the self-hosted raw-app mount does. Segment 1 (#22772, PR #22779) declared the member ITriggerApiService.handleInboundHook on slot trigger-api; the member's implementation is #22774, which is not on main yet. #22757 remains open.

What changed

@objectstack/core (minor, Clause-② widening)

  • security/auth-gate.ts: the allow-list's first PARAMETERISED row, POST /automation/hooks/:flowName/:hookId. It matches exactly four segments in the dispatcher's spelling, optionally under one /environments/:id scope (the one spelling the dispatcher's scope strip removes), POST only. No mount base, no retired projects scope, no spelling with an empty segment, no percent-decoding, literals case-sensitive. The segment count is pinned by the row's own length.
  • isAuthGateAllowlisted(path, method?) and evaluateAuthGate(user, path, method?) take an optional method. The six existing rows ignore it, so every caller that passes none is answered exactly as before. The new row needs it: no method, no exemption on that row.
  • New export matchInboundHookRoute(path, method): the ONE reading of the route. The row matches through it, and the dispatcher's floor, branch and membership skip ask it too, so the reader that admits the route and the reader that serves it cannot disagree on any spelling. It returns the two segments raw.
  • security/anonymous-deny.ts: shouldDenyAnonymous passes its method to the allow-list beside its path. Its pathless guard is unchanged and still decides first.

@objectstack/runtime (minor)

  • domains/automation.ts: the /automation domain's anonymous floor (ADR-0056 D2, the [17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #5519 floor) now asks the shared decision with its route path and method, so the row is honoured BY THE FLOOR. The very next statement hands the admitted route to the new module, ahead of the run-read gate, the authoring gate and the automation service probe. For every other /automation path and method the floor's decision is the one it always was.
  • domains/automation-hooks.ts (new): resolves the request kernel's trigger-api slot per request and forwards context.request with its body UNREAD. It hands the two parameters decoded (an escape that does not decode is passed raw, as the /meta domain does) and returns the member's Response as it is. An absent slot or member answers 501 NOT_IMPLEMENTED, never ROUTE_NOT_FOUND. A member that throws, or a body a transport already consumed, answers a sanitised 500, logged. It adds no credential rule and no signature check of its own: the member is the one verifier.
  • http-dispatcher.ts: the ADR-0069 gate passes the request's method to the allow-list. The membership gate skips the route for POST only, through the same reading, so a signed-in caller is judged exactly as an anonymous one (the /approvals/act precedent). GET /automation/hooks/runs/:runId is a run read of a flow named hooks, and stays checked.
  • route-ledger.ts: one public row, POST /automation/hooks/:flowName/:hookId, domain /automation; census 86 to 87.

Bookkeeping (no published surface): scripts/check-route-envelope.mjs declares automation-hooks.ts with handBuilt: 0. The dogfood probe census moves 86 to 87 rows (an existing domain key, so keys stays 23). The anonymous-deny-automation matrix row's note names the one admitted route. The showcase live-mount parity gate pins the row as served by the trigger's raw-app mount self-hosted, with nothing on the port answering it.

Landing point, measured. The registry is first-match and /automation claims its whole subtree (match: 'segment'), and its floor is the domain's first statement. So a separately registered route needs a parameterised claim the registry cannot express, and it would leave the allow-list row unread by any anonymous floor. The route therefore lives inside the /automation domain, right after its floor. domains/automation.ts is in the claim's surface for exactly this case ("only if the existing /automation domain must yield this exact route"). domain-handler-registry.ts is untouched.

Ruling A's three conditions, as measured

  1. Exactness. Pinned in core/security/auth-gate.test.ts: PIN 1 to 4 and a BOUNDARY sweep. The sweep covers 133,332 paths times six methods, method absent included, against a transcription of the pre-row predicate from 12b9daf7. The set of (path, method) answers that moved is EXACTLY the hook route, POST in either case, unscoped or under one /environments/:id scope. Every move is newly exempt, none newly gated. /automation/hooks, /automation/hooks/x, /automation/hooks/x/y/z and every other /automation path stay gated, and on the wire every one of them still answers an anonymous caller the floor's 401 UNAUTHENTICATED (the answer each gave on the base commit, measured before any change).

  2. The verifier is the boundary. Here is what sits between the allow-list and the verifier on the hosted shape (createHonoApp catch-all, then HttpDispatcher.dispatch):

    • the catch-all parses a CLONE, so the raw request reaches the dispatcher unread;
    • the identity step (resolveRequestScope) resolves an anonymous sender to no user and refuses nothing. Its one refusal, an authz-store outage 503, is an infrastructure fault, not a gate on this route;
    • the ADR-0069 gate exempts the POST through the row;
    • the membership gate skips the POST through the same reading;
    • the scope strip runs, the /automation domain is claimed, and the floor admits the POST through the row;
    • the next statement forwards to the member.

    A tripwire pin proves no /automation gate or probe reads anything first: the automation and security services are never touched, with a control showing the tripwire fires on an ordinary request. A non-POST meets the floor's refusal before any route shape leaks: 401 even with neither service composed, never a 501. An unsigned POST reaches the verifier, whose one refusal ([finding] trigger-api: the inbound hook matches the flow and hook id before it verifies the signature, so an unsigned post's 401 versus 404 tells which flow names are armed #22806) does not tell an armed flow from an unarmed one.

  3. Replay protection is not read here, as the ruling directs. (Its own card has since landed core's timestamped scheme, feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated #22803. Nothing here depends on it.)

The PM's mechanism assumptions, as measured

  1. Held. ITriggerApiService.handleInboundHook? is on main under slot trigger-api, and its docblock names this domain as its one caller.
  2. Held. The /webhooks/redeliver sibling is the shape for per-request slot resolution, typed absence, sanitised faults and the no-double-mount control.
  3. Measured, and it decided the landing point. On 12b9daf7, on a dispatcher-only kernel, the /automation domain claims /automation/hooks/f/h. The anonymous floor runs INSIDE that domain, as its first statement: an anonymous POST answered 401 UNAUTHENTICATED, and a signed-in POST fell through unhandled.
  4. The method. The matcher took a path only. The narrowest seam is an OPTIONAL trailing method on isAuthGateAllowlisted / evaluateAuthGate, read by parameterised rows only. Every existing caller (REST enforceAuth, the dispatcher gate, shouldDenyAnonymous) is unchanged byte for byte when it passes none. shouldDenyAnonymous already carried method, and now forwards it.
  5. trigger-api: implement the declared inbound-hook member through the same verifier, read http.server before the alias, and keep the self-hosted raw-app mount byte-unchanged (trigger-api segment 3 of ruling A on #22757) #22774 is not on main (origin/main 55382dc02: trigger-api registers no trigger-api service yet). The pins use a member double whose verifier is core's real verifyHttpSignature, the function ApiTrigger.handleRequest verifies with. The end-to-end leg on the real member is owed by whichever of runtime + core: an exact POST /automation/hooks/:flowName/:hookId dispatcher domain and its parameterised ADR-0069 allow-list row, anonymous through to the trigger's HMAC verifier only (trigger-api segment 2 of ruling A on #22757) #22773 / trigger-api: implement the declared inbound-hook member through the same verifier, read http.server before the alias, and keep the self-hosted raw-app mount byte-unchanged (trigger-api segment 3 of ruling A on #22757) #22774 lands second (triage unlock 6107344979).

Pins

  • packages/runtime/src/domains/automation-hooks.test.ts (19), on the real HttpDispatcher, a kernel with no http.server:
    • a correctly signed anonymous POST reaches the verifier and is accepted (202), with its own request, the spaced body byte-exact, and the parameters decoded;
    • a wrongly signed, body-tampered, unsigned, malformed, unknown-flow or wrong-hook POST is refused BY THE VERIFIER, which runs every time, and its 401 INVALID_SIGNATURE passes through;
    • a signed-in caller is judged as an anonymous one;
    • condition 1 on the wire, the tripwire and the [17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #5519 ordering;
    • the typed 501s, read per request, and the sanitised 500s;
    • the gates: a signed-in non-member reaches the verifier on both spellings, and the same caller stays membership-checked on every other method and hook-shaped path. A session owing an authentication step passes with its POST and is gated on every other method;
    • the pointer's extra self-test (6106817103): over 25 spellings (trailing slashes, query, encoded parameters, empty segments anywhere, empty scope id, projects scope, doubled prefix, two scopes, an encoded literal, case, wrong counts), admitted by the row if and only if served by the route with the same decoded parameters, for an anonymous caller and for a gated session. Every other spelling is refused before the member;
    • CONTROL: on a kernel with an http.server, the dispatcher plugin mounts no route that answers POST /api/v1/automation/hooks/:flowName/:hookId, with a matcher control. The trigger's raw-app mount stays that kernel's one door: dispatcher-plugin.ts is untouched, and the showcase parity gate re-checks it on a real self-hosted boot.
  • packages/core/src/security/auth-gate.test.ts (+7) and anonymous-deny.test.ts (+1).

Ablations

Each was one-off, run from committed state, through scripts/ablation-replace.mjs in WRAP mode. Each mutation landed (anchor count 1 to 0, blob changed) and each restore was proven: blob equal to HEAD, git diff HEAD empty, git status --porcelain clean afterwards. The runtime suite resolves @objectstack/core to src through its vitest alias and imports the runtime by relative path, so no dist/ sits on these paths.

mutation result
the allow-list row removed runtime pins 11 of 19 red: every anonymous reach answers the floor's 401, so the row is what admits the sender
the branch after the floor removed 14 of 19 red
the membership skip removed 1 of 19 red: the non-member reach pin
the method dropped from the ADR-0069 gate call 2 of 19 red: both gated-session pins
the floor's path argument dropped 10 of 19 red
the core method check removed core 5 of 43 red: PIN 2, the reading, evaluateAuthGate, BOUNDARY, shouldDenyAnonymous
the empty-segment refusal replaced by a drop-empty reading core PIN 4 and the reading red; runtime 4 of 19 red, the spelling-agreement pins among them (//automation/hooks/f/h would be admitted by the row and served by nothing)

Verification (at 11fac6c955, after git merge origin/main, pnpm install --frozen-lockfile and a full turbo run build, 73 of 73)

  • pnpm --filter @objectstack/core test: 94 files, 2379 passed. pnpm --filter @objectstack/runtime test: 351 files, 5070 passed, 19 skipped. Both packages' typecheck, test layer included: exit 0.
  • pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/authz-conformance.test.ts test/authz-probe-blind-spot.test.ts test/route-ledger-live-mount-parity.dogfood.test.ts: 3 files, 100 passed (the parity gate booted the real showcase).
  • The 92 commands node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives on this tree all exit 0, pnpm check:pm-dispatch-gates and pnpm check:dual-build-cjs-loads included. --ran answers "92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN". node scripts/check-plugin-teardown-shape.mjs --self-test first answered exit 3 (its pinned fixture commit was outside this shallow clone, not a measurement), and after git fetch origin 621a487607 it answered exit 0, 48 cases.
  • Lint, a proven narrowing: eslint --no-inline-config --format json over the 14 touched lintable files reads 14 results, 0 errors, 0 warnings. ESLint.isPathIgnored says all 14 are in the config's population. The config enables no type-aware linting and reads only scripts/slot-lookup-baseline.json and scripts/query-options-erasure-baseline.json at load, neither touched, so this diff cannot move any untouched file's verdict.
  • packages/qa/http-conformance is untouched: the end-to-end leg on the real member is the second lander's (above).

Acceptance notes


Generated by Claude Code

…r — one parameterised POST allow-list row, honoured by the /automation floor

WIP: the allow-list's first parameterised row (exactly four segments, POST
only, dispatcher spelling) and the inbound-hook door the /automation domain
hands its one admitted route to, forwarding to the trigger-api slot's
handleInboundHook. Pins follow.

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
Co-authored-by: Claude <noreply@anthropic.com>
… reached, floor kept, gates signature-only, spellings agree

Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6
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 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/runtime, touching 26 documentable anchor(s).

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

  • content/docs/api/environment-routing.mdx (via HttpDispatcher (symbol, a top-level class), enforceProjectMembership (symbol, a method of class HttpDispatcher), /environments/:id (route, a path literal in a comment on a changed line))
  • content/docs/automation/flows.mdx (via flowName (symbol, a field of interface InboundHookRouteMatch))
  • content/docs/automation/jobs.mdx (via flowName (symbol, a field of interface InboundHookRouteMatch))
  • content/docs/automation/webhooks.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/concepts/north-star.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • content/docs/kernel/cluster.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/permissions/authorization.mdx (via shouldDenyAnonymous (symbol, a top-level function))
  • content/docs/permissions/system-context.mdx (via handleAutomationRequest (symbol, a top-level function), shouldDenyAnonymous (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/protocol/kernel/metadata-service.mdx (via /environments/:id (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/implementation-status.mdx (via /environments/:id (route, a path literal in a comment on a changed line))
  • content/docs/releases/v12.mdx (via shouldDenyAnonymous (symbol, a top-level function))
  • content/docs/releases/v15.mdx (via shouldDenyAnonymous (symbol, a top-level function))
  • content/docs/releases/v17/17-0.mdx (via flowName (symbol, a field of interface InboundHookRouteMatch))
  • content/docs/releases/v17/17-5.mdx (via HttpDispatcher (symbol, a top-level class), flowName (symbol, a field of interface InboundHookRouteMatch))

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

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

Coarse fallback — 44 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 55382dc02a2168585a024ba142b98d8bce9910e6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 55382dc02a2168585a024ba142b98d8bce9910e6

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 11fac6c95544408a05b44aa5a5e71e9f7ce0e1d8
Local-runs: none

Reviewed against: card #22773 (body and all five comments; the seat's ACCEPT 6111057759 read as a claim to verify), ruling A on #22757 (director record 6105447950, read at its source), the contract packages/spec/src/contracts/trigger-api-service.ts on origin/main, PR #22864's body, file list and net diff against merge base 55382dc02a (15 files, +1196 / -21), and the 39 check-runs on this head: 33 success, 6 skipped (opt-in or path-filtered), 0 failed, 0 pending. Test Core x7, TypeScript Type Check x5, Dogfood Regression Gate x4 (real showcase boot), Lint & Repo Gates, Check Changeset x2, Governed Surface Queue Guard, Spec property liveness, the four PR-shape guards, Temporal Conformance, Dogfood Verify CLI, Build Core, Check Documentation Links and Flag docs are all green. No governed path in the file list; no packages/spec and no packages/triggers/trigger-api file.

① Derived judgments

Each accept-set or public-surface change the diff implies, judged right or wrong:

  1. isAuthGateAllowlisted(rawPath, method?) and evaluateAuthGate(user, path, method?) gain an optional trailing method — RIGHT, and no existing caller's answer moves. The exact-row half (matchesAllowlistedRoute) is byte-identical and still decides first; the parameterised half answers null unless a method string is passed (matchParameterisedRoute). Callers read on the head: REST enforceAuth (packages/rest/src/rest-server.ts:2373) passes the path only, so its answer is unchanged; the dispatcher's enforceAuthGate now passes the method, and for a gated session that moves exactly one (path, method) class: POST on the four-segment hook route, unscoped or under one /environments/:id scope, which ruling A directs (the signature is the only credential, equal to the self-hosted mount, which has no gate). Pinned by the BOUNDARY sweep in auth-gate.test.ts: a corpus of every path to five segments over a ten-token vocabulary plus the six-segment scoped shape, six methods including none, judged against a transcription of the pre-row predicate; newly gated is the empty set, newly exempt is exactly the expected set built independently of the predicate. PIN 2's control: the six exact rows and the auth prefix stay exempt with a method or without.

  2. New export matchInboundHookRoute(path, method) and type InboundHookRouteMatch from @objectstack/core (security/index.ts) — RIGHT. Additive. It is the one reading the row matches through, and the three runtime readers (the /automation floor's branch, the ADR-0069 gate through isAuthGateAllowlisted, the membership skip) ask it, so "admitted" and "served" cannot diverge on a spelling: the row-equals-reading identity is pinned in core, and the admitted-iff-served relation is pinned on the wire over 25 spellings for an anonymous caller and for a gated session, with 9 admitted and the rest refused before the member. Parameters come back raw and the serving domain decodes them (the /meta precedent), an undecodable escape passed raw — pinned. Exporting from core is the only way to have one reading without a core-to-runtime cycle.

  3. The /automation floor now passes path: routePath to shouldDenyAnonymous — RIGHT, and no pre-existing allow-list row can exempt any /automation/... spelling. routePath is /automation plus the domain's subpath (req.path.substring(11)), scope already stripped. In matchesAllowlistedRoute every exemption needs the first post-base segment to be auth, a scope word, or one of the six literals; with base [] that segment is automation, and with base api or api/v1 the first segment fails the base test, so the exact rows answer false on every /automation path (the BOUNDARY corpus contains /automation/health-shaped and /automation/api/v1/...-shaped paths and records no move). The pathless guard still decides first (pinned in anonymous-deny.test.ts). The REST seam passes no path to this decision (rest-server.ts:2383, by its own comment) and the other eleven call sites on the head pass none, so this floor is the only seam whose answer the row can change.

  4. The parameterised row against ruling A condition 1 — RIGHT. INBOUND_HOOK_ROUTE = { segments: ['automation', 'hooks', ':flowName', ':hookId'], methods: ['POST'] }; rest.length !== route.segments.length answers null, which pins the count; literals compare raw and case-sensitive (no percent-decoding: /automation/%68ooks/f/h and /Automation/hooks/f/h stay gated); no mount base (/api/v1/automation/hooks/f/h stays gated); the retired projects scope is refused; any empty segment refuses the whole spelling, stricter than the exact rows' drop-empty reading and the right direction (//automation/hooks/f/h would otherwise be admitted by the row and served by nothing, the spelling disagreement the segment-1 pointer 6106817103 asked to pin). The one optional /environments/:id scope is the dispatcher's own spelling (the ADR-0069 and membership gates run before the strip and see it; the floor sees it stripped), the same allowance the /approvals/act row carries, and the count after the scope is still four. The card's three (/automation/hooks, /automation/hooks/x, /automation/hooks/x/y/z), the bare domain, deeper counts, sibling literals and the domain's real routes at every depth are pinned gated at the predicate (PIN 3, PIN 4) and on the wire (17 paths x 5 methods answer the floor's 401 UNAUTHENTICATED, member never called; every non-POST on the exact route likewise). Nothing wider than the ruling's letter is admitted.

  5. The membership-gate skip — RIGHT. if (matchInboundHookRoute(path, method)) return null; sits beside the /approvals/act skip, before the environmentId read, POST only, through the same reading. Pinned on a multi-tenant host: a signed-in non-member reaches the verifier on both spellings with zero sys_environment_member reads; the same caller is 403 PROJECT_MEMBERSHIP_REQUIRED with one membership read on GET /automation/hooks/runs/r1 (a run read of a flow named hooks, the reason the row is POST-only), on GET and DELETE of the route, on POST to three- and five-segment hook-shaped paths, and on the empty-segment spelling. The contract's "a signed-in caller is judged exactly as an anonymous one" requires it; without the skip something would sit between the allow-list and the verifier for a signed-in sender.

  6. Condition 2, "nothing sits between the allow-list and the verifier" — RIGHT, measured on the hosted shape from the code on the head. The hono catch-all hands dispatch() { request: c.req.raw } and parses its body argument from raw.clone() (packages/adapters/hono/src/index.ts:325 and :791; pinned by hono-catch-all-raw-request.test.ts, runtime + hono: an exact /approvals/act dispatcher domain that forwards to the approvals service member, and a catch-all that stops consuming non-JSON bodies (segment 2 of ruling A on #22438) #22576). Inside dispatch(): the trailing-slash strip; the liveness carve-out (no match); resolveRequestScope (an anonymous sender resolves to no user; its only refusal is the authz-store outage 503, an infrastructure fault, not a gate on this route); the ADR-0069 gate, exempt through the row; the membership gate, skipped through the same reading; the scope strip; the registry resolves /automation (segment match); the floor admits the POST through the row; the very next statement hands the match to handleInboundHookRequest, ahead of the run-read gate, the authoring gate and the automation service probe. That module reads no session, no signature header and no body: it resolves the trigger-api slot per request, checks member presence, refuses a body the transport already consumed as a sanitised 500 (a transport fault that would otherwise reach the sender as a bad signature; the /webhooks/redeliver shape), decodes the two segments and forwards context.request itself. The tripwire pin proves the automation and security services are never touched on the admitted path, with a control showing the tripwire fires on an ordinary request; the acceptance pin proves the member receives the transport's own Request object with the spaced body byte-exact and verifies it with core's verifyHttpSignature, the function ApiTrigger.handleRequest verifies with (packages/triggers/trigger-api/src/api-trigger.ts:311 on origin/main).

  7. Condition 2, the [17.0-rc2验收] 安全:REST /actions 与 /automation 派发路由缺少匿名拒绝门 —— 未认证调用者可触发 system 提权的 RLS/FLS 绕过写入 #5519 ordering — RIGHT as far as the ruling's text reaches. A non-POST on the route meets the floor's 401 UNAUTHENTICATED with neither automation nor trigger-api composed, never a 501 (pinned). An unsigned POST is admitted by the row (the row reads path and method; it cannot read a signature, and the contract forbids a pre-check between the allow-list and the verifier) and meets the verifier's one 401 INVALID_SIGNATURE, the same answer for a wrong secret, a tampered body, a malformed header, an unknown flow and a wrong hook id (six cases pinned, member called each time, none accepted), so no route shape leaks past it. The residual the dev flagged, service presence on this route, is judged in ③.

  8. Route ledger, census and gates — RIGHT. One public row for POST /automation/hooks/:flowName/:hookId under domain /automation, tool-written census 86 to 87 (check:route-ledger-census green); the dogfood census pair moves 86 to 87 with keys 23 and blindSpot 0 (an existing domain key); the live-mount parity gate names the self-hosted raw-app door as the reason the port never answers the row; check-route-envelope.mjs declares automation-hooks.ts with handBuilt: 0 (the member's Response goes back as result, every own refusal through deps.error). The Dogfood Regression Gate on this head booted the real showcase and is green.

  9. Route and surface ownership rules — RIGHT. Rule 1: two doors on two shapes by the ruling's letter; the CONTROL pin shows the dispatcher plugin mounts nothing answering the path on a kernel with http.server (with a matcher control that does find the three-segment trigger door), and dispatcher-plugin.ts is untouched. Rule 3: absence answers 501 NOT_IMPLEMENTED (derived from the status by apiErrorResponse) naming the missing service or member and the remedy, read per request (a slot registered after a 501 is reached by the next request, pinned), never ROUTE_NOT_FOUND, as the contract requires. Rule 5: not a GET, reads no req.query; the query rides inside the forwarded Request to the member exactly as the self-hosted mount receives it, so no closed set is owed by this domain. Prime Directive [WIP] Add Chinese version of the documentation #13: ADR-0069, ADR-0056 D2 and ADR-0041 ids are in the code; the ADR-0076 anchor's invariant on http-dispatcher.ts (scope, then both gates, then the registry) holds, the skip living inside the membership gate and the hook branch inside the domain after its floor.

  10. Landing point (the recorded deviation) — RIGHT. createAutomationDomain registers prefix: '/automation' with the default segment match, the registry is first-match, and DomainRoute cannot express :flowName/:hookId; a sibling registration would have to precede /automation and would leave the row unread by any anonymous floor, while the ruling says the row is honoured by the floor. domains/automation.ts is edited under the claim's conditional clause; domain-handler-registry.ts is untouched.

  11. Observation, not a flag: trigger-api is not a key on ServiceSlotContracts, so the slot resolves through the untyped overload and the door casts to Pick of ITriggerApiService, exactly as the redeliver sibling does. Typing the key is a packages/spec matter the card bars for this segment.

② Semver level

.changeset/22773-automation-hooks-domain.md: @objectstack/core minor, @objectstack/runtime minor — matches what the diff publishes. Core: one new export (matchInboundHookRoute, InboundHookRouteMatch), two optional trailing parameters, one new exemption; nothing removed, no existing answer narrowed (BOUNDARY: newly gated is empty). Runtime: one new door and module, one new ledger row; nothing removed. Not breaking, so no ADR-0087 marker is owed (check-adr-0087-registration on the head: no declared-breaking changeset). packages/qa/dogfood is private: true and scripts/ publishes nothing; packages/spec is untouched, so no artifact regeneration is owed. The changeset body's sentences were checked against the diff: the method semantics on both functions, the forwarding in shouldDenyAnonymous, the raw-segment export, the two 501s and the sanitised 500s, and the self-hosted-unchanged claim (the CONTROL pin). Check Changeset is green on the head.

Clause-②: yes (widening) — one exact parameterised POST route passes the ADR-0069 gate, the /automation anonymous floor and the membership gate with no session, as ruling A directs; the precedent is the ['approvals', 'act'] row declared yes on #22577. yes takes at least minor; both packages are minor.

③ Boundary flags

Every dev flag and open question from os-dev-report 6110990230, answered:

Implemented-by: claude/issue-22773-automation-hooks-domain
Reviewed-by: session_01VoSxBQujKLZKPwK2u5ehQ6

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 16:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 16:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit fe58bbc Oct 11, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22773-automation-hooks-domain branch October 11, 2026 17:36
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