Skip to content

plugin-approvals mounts its ADR-0043 action pages (/api/v1/approvals/act) only through http.server getRawApp, so a hosted tenant kernel, which has none, answers 404 ROUTE_NOT_FOUND to every approval e-mail link #22438

Description

@objectstack-fleet

Blocked-by: #22576

Ruled: 6079645593 · letter A + sweep · 2026-10-09T11:06Z

Filing gate ①: a reproduced product defect with a named landing site. Filed by the repo:cloud#1 seat (session session_01WVbr5J6u8BHh8EyFtcWciH) from objectstack-ai/cloud#2549. Reader: the seat that owns plugin-approvals (triage routes it). The consumer, cloud#2549, waits on this card through a Blocked-by: line and unlocks when cloud's framework pin carries the fix.

Defect

  • Landing site: packages/plugins/plugin-approvals/src/approvals-plugin.ts, mountActionPages (lines 366–465 on main today). It reads http.server (alias http-server) and mounts GET / POST /api/v1/approvals/act on http.getRawApp() (lines 381–385). With no raw app it returns silently.
  • A hosted tenant kernel registers no http.server. The process-level server lives outside the kernel, and the hosted runtime serves a kernel's HTTP surface through what the kernel registers. So the mount is skipped, and the actionable-link pages mounted log line never appears.
  • Reach, measured (cloud#2549, the maintainer's reading 6076690389):
    • Setup: staging environment 56891812 on the Business and then the Enterprise plan (both carry plugin-approvals), objectos 22234f2b.
    • GET https://os-2ph4c1.objectos.app/api/v1/approvals/act?token=probe-invalid-2549 answers HTTP 404, {"code":"ROUTE_NOT_FOUND","route":"/approvals/act"}.
    • The plugin's other start-up line (SLA escalation scan scheduled) is present, so the plugin loaded.
    • Every approval e-mail's action link targets this path, so on a hosted plan that carries approvals, a one-click approve/reject from an e-mail lands on raw 404 JSON.
  • The path that already exists: plugins that serve HTTP on hosted kernels register their routes through the kernel's route registry rather than a raw app (plugin-auth does). The approvals action pages should go the same way, so every host serves them.

Acceptance

  1. On a kernel with no http.server (the hosted shape), GET /api/v1/approvals/act?token=… renders the ADR-0043 confirm page, and POST records the decision. Pin it with a test on that kernel shape.
  2. On a kernel WITH http.server (self-hosted), behaviour is unchanged.
  3. ⛔ No silent skip remains: if the pages cannot be mounted, the plugin says so loudly once, so a host that drops them is visible.

Consumer side

cloud#2549 goes pm:blocked on this card. It unlocks when cloud's .objectstack-sha carries the fix (the v18 pin move, cloud#2709, or a later one). The consumer's acceptance is a real approval e-mail's link opening the confirm page on a staging environment on a carrying plan.

Dedupe: semantic search of this repo for 「plugin-approvals action pages approvals/act getRawApp http.server hosted kernel route registry」 found 9 hits, all closed and all about other approvals surfaces (#21984, #3504, #8580, #7213, #7231, #3800, #3310, #10734); none carries this.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, priority:p2 · domain:services · area:workflow (pm:queue stands). Direction: mount the action pages through the kernel's route registry, as plugin-auth does

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-09T08:54Z. ⛔ Not a claim, ⛔ not a dispatch.

    Triage: lands in packages/plugins/plugin-approvals/src/approvals-plugin.ts (mountActionPages) ⇒ domain:services. Rationale: plugin-approvals belongs to that lane.

    • Why p2: on every hosted plan that carries approvals (Business and Enterprise), each approval e-mail's one-click link lands on raw 404 JSON. That is a paid feature broken on the hosted shape. It was measured on staging (objectstack-ai/cloud#2549, reading 6076690389).
    • Direction: the card's own acceptance.
      • Register GET / POST /api/v1/approvals/act through the kernel's route registry, the way plugin-auth serves its routes on a hosted kernel, so every host serves them.
      • ⛔ No silent skip: a host that cannot mount the pages hears it once, loudly.
      • Self-hosted behaviour is unchanged.
    • Pins:
      • on a kernel with no http.server, GET renders the ADR-0043 confirm page and POST records the decision;
      • control: a kernel with http.server behaves as today.
    • Consumer: cloud#2549 unlocks on the install face, when cloud's .objectstack-sha carries the fix (cloud#2709 or a later move).
  2. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4 · 2026-10-09T09:06Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22438-approvals-act-dispatcher
    Worktree: objectstack-issue-22438
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)

    Executes triage's direction (6077709759): plugin-approvals serves its ADR-0043 action pages on a kernel with no http.server (the hosted shape), loudly when it cannot, and unchanged on a self-hosted kernel.

    Measure first. plugin-auth's own source (auth-plugin.ts near :809) says a cloud tenant kernel builds it with registerRoutes: false, because "the per-env kernel has no http-server and the host worker proxies the auth routes to it". The card's lead, that plugin-auth registers through a kernel route registry, is therefore unproven. The 404 ROUTE_NOT_FOUND the card measured has the shape of runtime's dispatcher (dispatcher-plugin.ts). Before building, the dev names the in-kernel surface that a dispatcher-only kernel serves /api/v1/* from, and how a plugin contributes a route to it.

    • If a plugin can contribute such a route from its own package, the build stays in plugin-approvals.
    • If it needs a runtime dispatcher change (domain:cli) or a host-worker change (objectstack-ai/cloud), the dev stops and reports. The seat then routes the remainder.

    File surface at origin/main da159f74:

    • packages/plugins/plugin-approvals/src/approvals-plugin.ts, mountActionPages: the action pages (GET and POST on the act path) are contributed through the surface a dispatcher-only kernel serves. Where http.server's raw app exists, behaviour is unchanged. Where neither is available, one loud line replaces today's silent return.
    • Tests in plugin-approvals:
      • pins on a kernel with no http.server: GET renders the confirm page, and POST records the decision;
      • controls: a kernel with http.server is unchanged; an invalid token gets today's result page.
    • The route ledger and its census, only if the gate demands them, repaired with the generator only.
    • One @objectstack/plugin-approvals changeset.

    Exclusions:

    • ⛔ The pages' authentication is unchanged: the action token alone, as ADR-0043 sets it. No session requirement is added and none is removed, and no other approvals route is opened.
    • ⛔ No packages/spec, no runtime, no objectstack-ai/cloud change (stop and report instead).
    • ⛔ Nothing else in plugin-approvals changes.

    Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier)
    Clause-②: no

    • A route that answers today on a self-hosted kernel now answers on a dispatcher-only kernel too, with the same authentication. No accept-set changes on any host that serves it today.
      Responsibility: this repository's own code: mountActionPages mounts only on http.server's raw app and returns silently without one | none: no other layer serves the act path on a dispatcher-only kernel | approvers on hosted plans that carry approvals, whose e-mail links land on 404
      Thread-read: 6077709759
      Serial constraints cleared:
    • No open PR touches plugin-approvals (15 open PRs read, at their file lists).
    • objectstack-ai/cloud#2549 is the consumer, Blocked-by: this card. It unlocks on cloud's framework pin move.
  3. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22438,
    "status": "needs_decision",
    "branch": "claude/issue-22438-approvals-act-dispatcher",
    "pr": null,
    "session": "session_01WkL6Eijt432S1Y7ekb6ovQ — the dispatching PM session (mode:subagent, the parent id; the harness Claude-Session line names the same id)",
    "premise_still_valid": true,
    "summary": "H2's stop condition held, so nothing was built: no commit, no PR, no label write. The defect is real and reproduced on origin/main da159f7 (H1). On a kernel with no http.server, the approvals service is registered and answers peekActionToken. But HttpDispatcher.dispatch answers GET and POST /approvals/act with 404 ROUTE_NOT_FOUND, carrying route '/approvals/act', the same code and route the card measured on staging. The plugin also prints no line about the pages. The self-hosted control (http.server with a raw app) serves both HTML pages. The card's lead is false. plugin-auth does not register its routes through a kernel route registry. On a tenant kernel it runs with registerRoutes false, and a runtime-owned /auth dispatcher domain serves its routes by passing them to the auth service. No surface reachable from plugin-approvals can add a route to a dispatcher-only kernel. The remainder needs runtime work (plus the hono adapter, core's auth-gate allow-list and a spec contract member) or cloud work. Both are fenced out. The four-axis options are in open_questions.",
    "h1_reproduction": {
    "tree": "origin/main da159f7 (worktree /home/user/objectstack-issue-22438, HEAD = BASE, clean). The closure of @objectstack/hono + @objectstack/plugin-approvals was built with turbo --concurrency=1 and OS_SKIP_DTS=1 (35 tasks).",
    "rig": "A scratch node script, never committed (copy in the seat scratchpad at issue-22438/h1-repro.scratch.mjs). A real ObjectKernel boots with ObjectQLPlugin and ApprovalsServicePlugin, the plugin's logger is captured, and a better-sqlite3 SqlDriver serves it, the same boot as plugin-shutdown-cancels-escalation-job.test.ts. Hosted shape: no http.server or http-server slot. The dispatcher surface is runtime's HttpDispatcher (dist), driven directly and through @objectstack/hono createHonoApp({ kernel, prefix: '/api/v1' }), whose ${prefix}/* catch-all is the in-repo host adapter that forwards to dispatch(). Control: the same kernel plus a stub http.server whose getRawApp() returns a real Hono app.",
    "hosted_readings": [
    "http-server slot: absent (both names)",
    "approvals service registered: true; peekActionToken('probe-invalid-2549') answers {ok:false, reason:'invalid'}",
    "'actionable-link pages mounted' line present: false. The plugin's full captured output names nothing about the action pages (no warn, no error), so the skip is silent.",
    "dispatch GET /approvals/act: {status:404, code:ROUTE_NOT_FOUND, route:'/approvals/act'}; dispatch POST: the same",
    "wire GET /api/v1/approvals/act?token=probe-invalid-2549 through createHonoApp: 404 application/json {success:false, error:{code:ROUTE_NOT_FOUND, message:'Route Not Found: /approvals/act', httpStatus:404, route:'/approvals/act', hint:...}}. Same code and route as the staging body; wire POST: the same"
    ],
    "selfhosted_control": [
    "'actionable-link pages mounted' line present: true",
    "raw-app GET: 200 text/html; charset=utf-8, title 'Invalid link · 链接无效'; raw-app POST (form-urlencoded token): 200 text/html, the same title"
    ],
    "verdict_lines": "Run 1: 'VERDICT command-exit 0 · held the lock 4s'. Run 2: 'VERDICT command-exit 0 · held the lock 3s'. Run 2 repeated the run with the wire capture fixed, see deviations."
    },
    "h2_answers": {
    "q1_surface": "On a dispatcher-only kernel nothing inside the kernel serves /api/v1/. The host's catch-all hands the path to HttpDispatcher.dispatch() (packages/runtime/src/http-dispatcher.ts:2551). createHonoApp shows this at packages/adapters/hono/src/index.ts:725, app.all of ${prefix}/, on a dispatcher the adapter builds itself (:303 new HttpDispatcher(options.kernel)). Routing is that INSTANCE's DomainHandlerRegistry, seeded in the constructor by registerBuiltinDomains() (:711-841) with a fixed list. That list has no /approvals entry, so the miss falls to routeNotFound(cleanPath) (:1162, :2831). That function is the only producer of a ROUTE_NOT_FOUND body that carries 'route' (DispatcherPlugin's own 404 in sendResultBase has none). On a multi-tenant host, mounts are host-global and services live in the per-project kernel (http-dispatcher.ts isMultiTenantHost doc near :544; dispatcher-plugin.ts near :1145).",
    "q2_plugin_contribution": "No. HttpDispatcher.registerDomainHandler() (:850) is public, but it lives on the dispatcher INSTANCE, and the host constructs that privately: in createHonoApp's closure, or in DispatcherPlugin.start() (dispatcher-plugin.ts:906). No kernel service exposes it: a grep of registerService for any dispatch or route name has zero hits outside tests. Measured: a domain registered on one HttpDispatcher is invisible to the one createHonoApp builds (probe not reached, 404). By design, registration stays dispatcher-owned, and a route bridges to a service SLOT (domain-handler-registry.ts:20-28 and its ADR-0076 D11 anchor). On a multi-tenant host, a tenant plugin registering there would also serve every tenant from one closure. The other plugin route seams all need http.server, which the tenant kernel lacks. The ai:routes hook and mountRouteOnServer skip without a server (dispatcher-plugin.ts:2134-2170, if (!server) return). The apis: endpoint step runs on http.server's setFallbackHandler, only under /api/v1/apps/NAMESPACE/, and only for metadata-declared JSON endpoints (api-endpoint-step.ts). No plugin serves its own route on this shape. Every plugin that serves HTTP outside the dispatcher mounts on http.server's raw app: plugin-auth auth-plugin.ts:2318, plugin-webhooks webhook-outbox-plugin.ts:393, metadata plugin.ts:605, cloud-connection. plugin-auth's hosted routes are runtime's /auth domain (runtime/src/domains/auth.ts:68-75), which forwards context.request to the request kernel's auth service handleRequest.",
    "q3_hosted_mapping": "NOT MEASURED in cloud code: add_repo for objectstack-ai/cloud was refused (no access). In-repo evidence answers 'catch-all, not a list'. The staging body carries route '/approvals/act' with the prefix stripped. Only dispatch()'s final fallback emits that field. No framework list names /approvals/act, so the host forwards unlisted /api/v1/* paths into dispatch(), the shape of createHonoApp's ${prefix}/* catch-all.",
    "verdict": "STOP before building. Question 2 has no answer inside plugin-approvals."
    },
    "measured_option_facts": [
    "F1 (measured, the H1 rig): createHonoApp's catch-all JSON-parses POST bodies. A form-urlencoded POST of token=... reaches dispatch() with body {}, and context.request's body is already consumed (reading a clone throws 'unusable'). The confirm page's HTML form cannot get its token through this door unless the adapter changes.",
    "F2 (measured): an HTML answer can pass this adapter only as result: Response (the #16383 passthrough). A probe domain returning one answered 200 text/html through direct dispatch. response.body is always c.json-ed.",
    "F3 (read, not run): dispatch() applies the ADR-0069 auth-policy gate (enforceAuthGate, http-dispatcher.ts:1332-1371) to every path not on core's isAuthGateAllowlisted list whenever a session is present. A browser carrying a gated session would get 403 at a dispatcher door where the raw-app door answers on the token alone. Serving the pages through dispatch() with authentication unchanged therefore needs an allow-list entry in packages/core.",
    "F4 (read, not run): DispatcherPlugin's sendResultBase renders a non-stream result.result with res.json (dispatcher-plugin.ts:518-520), so a Response there serializes to {}. A self-hosted dispatch() mount of the pages would need that exit changed too. Keeping the self-hosted raw-app mount avoids it (DispatcherPlugin mounts no catch-all, so nothing is mounted twice)."
    ],
    "pins": "NOT MEASURED: pins and ablation, reason: H2's stop condition held, so nothing was built and no pin was written.",
    "auth_unchanged": "NOT APPLICABLE: nothing was built. The surface's default was measured instead (F3): a dispatcher door adds the ADR-0069 session gate, which the raw-app door does not apply.",
    "changeset": "none: no diff. The claim's Clause-②: no was never exercised.",
    "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths, worktree): exit 2. It printed 'this branch changes nothing against origin/main (merge base da159f7) — nothing to derive'. --ran reconciliation and pnpm check:error-status-conformance: NOT RUN, reason: no diff. Build under the lock: 'VERDICT command-exit 0 · held the lock 94s (1m34s)', turbo 'Tasks: 35 successful, 35 total'.",
    "tests": "H1 only (no suite run, nothing changed). From packages/plugins/plugin-approvals: OS_VERIFY_LOCK_SLOT=dev-22438 NODE_OPTIONS=--max-old-space-size=3072 bash scripts/pm/os-verify-lock.sh -c 'node h1-repro.scratch.mjs'. Both runs exit 0; readings in h1_reproduction. Outputs: seat scratchpad issue-22438/h1.out and h1b.out.",
    "mcp_calls": "0 GitHub MCP calls. 1 non-GitHub MCP call: claude-code-remote add_repo for objectstack-ai/cloud, access read, refused (no access) and not retried.",
    "api_writes": "1: POST /repos//issues/22438/comments (this report, through scripts/pm/post-stamped.mjs). Plus 1 git push, a git op and not REST: the empty branch, the rule-1 write probe. Reads: gh api GET of issue 22438 and its comments; one GET to repos/objectstack-ai/cloud answered 403.",
    "deviations": [
    "add_repo for objectstack-ai/cloud (read) was refused, so H2 question 3 is answered from in-repo evidence and marked NOT MEASURED in cloud code. Not retried by any other route.",
    "The measurement build used OS_SKIP_DTS=1 (JS only), so no type claim rests on it.",
    "The H1 script lived for a while in my worktree at packages/plugins/plugin-approvals/h1-repro.scratch.mjs. It was never committed and was deleted before git status read clean.",
    "The first H1 run's capture of what dispatch() receives through createHonoApp saw nothing. That was a null capture, not a reading: it patched runtime's ESM HttpDispatcher class, but @objectstack/hono's dist requires the CJS build, a separate class instance. Run 2 patched the CJS class, and F1 cites run 2. The other rows of run 1 and run 2 agree.",
    "The empty branch claude/issue-22438-approvals-act-dispatcher stays on the remote at da159f7. It is the rule-1 write probe. No PR was opened and no label was written; both are spent only if the decision brings the build back."
    ],
    "files_changed": [],
    "open_questions": [
    {
    "question": "How should a dispatcher-only (hosted) kernel serve the ADR-0043 action pages, GET and POST /api/v1/approvals/act? Nothing inside plugin-approvals can contribute a route there (H2), so each option below crosses the fence: runtime/spec/core/adapter, or cloud.",
    "options": [
    "A: a runtime-owned /approvals/act domain that forwards to the approvals service slot, the /auth shape. The package work: (1) plugin-approvals adds one transport-neutral service member that answers the pages from a web Request, rendering on GET and redeeming on POST, so the page logic keeps one owner. (2) packages/spec declares that member, optional, on the approvals service contract. (3) packages/runtime gives HttpDispatcher an exact /approvals/act domain for GET and POST. It forwards context.request to the request kernel's approvals slot and returns the Response as result. A slot or member that is absent gets a typed 404 or 501 instead of ROUTE_NOT_FOUND, which is the loud half. (4) The @objectstack/hono catch-all stops consuming non-JSON bodies (F1). (5) packages/core puts the act path on the ADR-0069 allow-list, so authentication stays token-only (F3). Self-hosted keeps today's raw-app mount byte-unchanged (no double mount, F4). Axes. Business need: it serves the measured pull, approval e-mail links on paid hosted plans (cloud#2549, staging), and also every createHonoApp embedder, which has the same 404. Long-term fit: it follows the hosted architecture as it stands: dispatcher-owned registration, a route bridging to a service SLOT, the /auth precedent, and the meta book-tree precedent of adding a dispatcher route that one transport lacked. AI-safety: the contract member is declared, absence is refused with a type, and no routing power moves into plugin space. Startup scope: one domain and one optional member, no new gate. Cost: 4-5 packages across the services, cli and spec lanes, so a trunk or 3-4 PRs. Cloud needs no change only if its host catch-all matches createHonoApp's, which is NOT MEASURED (cloud unreadable).",
    "B: the cloud host serves the path by proxying to the tenant kernel's approvals service, the way plugin-auth's own comment says the host worker proxies the auth routes. plugin-approvals exposes the same transport-neutral member, and the cloud host mounts /api/v1/approvals/act ahead of its catch-all. Axes. Business need: it fixes hosted plans and is the fastest unlock of cloud#2549, but createHonoApp embedders keep the 404. Long-term fit: a second route list lives in the host and drifts, and every raw-app-only plugin would repeat it. AI-safety: the route lives in a repo the framework's agents do not read, so the framework's own answer to 'who serves this path' is wrong. Startup scope: the narrowest change in this repo. Cost: a small plugin-approvals change plus cloud.",
    "C: a generic per-kernel plugin route seam: a kernel service of transport-neutral routes that dispatch() consults on the request kernel after its own registry. Axes. Business need: one measured route of pull (the plugin-webhooks redeliver endpoint is the same class but unmeasured). Long-term fit: it closes the class, but it reverses the anchored ADR-0076 D11 decision (registration stays dispatcher-owned, no routing power in plugin space), so it needs a new ADR. AI-safety: the worst of the four, since any plugin could declare a route and choose its own gates, the gate-bypass class the adapter's catch-all comment warns about. Startup scope: it widens the surface without pull. Cost: spec, core, runtime, the adapter and an ADR.",
    "D: ship only the loud-absence half now, plugin-local and Part of #22438: one warn when http.server's raw app is missing. Axes. Business need: the paid feature stays broken. Long-term fit: on the hosted shape that warn would print on every tenant boot, and it becomes false once A or B lands, because the tenant kernel cannot tell whether its host serves the path. So the loud line belongs to the chosen option's design, not ahead of it. AI-safety: neutral. Startup scope: the smallest. Cost: plugin-approvals only."
    ],
    "recommendation": "A, keeping the self-hosted raw-app mount so self-hosted stays byte-unchanged. A wins three axes: it serves the measured hosted pull and the embedders together, it follows the dispatcher-owned, slot-bridging architecture the hosted shape already runs (/auth), and it keeps the route declared and typed inside the framework. Nothing is hidden in the host and no plugin-space routing is added. The trade, laid out for the maintainer: B unlocks cloud#2549 fastest with the least change in this repo, but the route then lives outside the framework's route table and embedders stay broken. The loud line (acceptance 3) should be designed inside the chosen option (in A, the domain's typed refusal), not shipped ahead as D. No new gate is recommended."
    }
    ],
    "out_of_scope_findings": [
    "carrier: the seat that routes #22438's remainder · noted, not filed. plugin-webhooks webhook-outbox-plugin.ts registerAdminRoutes mounts POST /api/v1/webhooks/redeliver only on the raw app of the deprecated http-server alias. That is the alias-only read approvals-plugin.ts fixed for itself under its #4251 B5 comment. It reports absence at debug, so on a dispatcher-only kernel it is the same family as this card. Read only, not reproduced, so not filed. Dedupe words: raw app route dispatcher-only kernel; http-server alias only; webhooks redeliver hosted 404; plugin route hosted tenant kernel."
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    待维护者裁决:托管环境里审批邮件的一键链接怎么接通 · domain:services 座位 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-09T09:22Z

    一句话问题: 在云上托管的 Business / Enterprise 计划里,审批邮件中的"一键批准/拒绝"链接全部打开成 404 JSON。这个修复在审批插件内部做不了:要么由框架路由层接管这个地址,要么由云宿主转发。走哪条路需要你定。

    背景(开发实测,os-dev-report 见本卡上一条评论;本座位已复核关键读数):

    • 在 main da159f74 上复现:内核没有 http.server 时(托管形态),审批服务本身已注册、可用,但请求落到 runtime 的 HttpDispatcher,返回 404 ROUTE_NOT_FOUND、route: '/approvals/act',与 staging 实测完全一致。插件对此一行日志都没有。自托管形态下页面正常。
    • 分诊给的线索不成立:plugin-auth 在托管内核上并不自己注册路由。它以 registerRoutes: false 运行,由 runtime 自带的 /auth 域转发给 auth 服务。
    • 插件能够到的接口里,没有一个能往只有调度器的内核里加路由:调度器的域注册表属于宿主私有实例,没有暴露成内核服务。这是 ADR-0076 D11 的既定设计,注册权在调度器一方,不下放给插件。

    Governing text:

    • ADR-0076 D11(packages/runtime/src/domain-handler-registry.ts:4-10):路由注册归调度器所有,一条路由桥接到一个服务槽位。
    • ADR-0069 认证策略闸(packages/core/src/security/auth-gate.ts 的 isAuthGateAllowlisted):走调度器的路径,带会话时都要过这道闸。
    • ADR-0043:审批动作页仅凭邮件里的令牌访问。

    前提与复查命令:

    • 调度器内置域里没有审批:git grep -n -i approvals origin/main -- packages/runtime/src/http-dispatcher.ts → 0 命中。
    • 托管形态下 auth 走 runtime 的域:git grep -n "registerRoutes: false" origin/main -- packages/plugins/plugin-auth/src/auth-plugin.ts。

    选项与真实代价:

    选项 做什么 客户能感知到的后果
    A runtime 增加一个 /approvals/act 域,转发给审批服务槽位(照 /auth 的做法)。审批插件加一个与传输方式无关的成员,从 Request 生成页面;spec 声明这个成员;@objectstack/hono 的兜底路由不再吞掉表单请求体;core 把这个路径放进认证闸白名单,保持只凭令牌。自托管照旧挂原生路由,不变。 托管环境和所有用 createHonoApp 嵌入的部署,邮件链接都能打开确认页。约 4–5 个包,跨 services、cli、spec 三条车道,要拆 3–4 个 PR。云侧要不要改取决于宿主的兜底转发是否与 createHonoApp 一致,这一点未实测(cloud 仓库本会话读不到)。
    B 云宿主在兜底转发之前单独挂这个地址,代理给租户内核的审批服务(照宿主现在转发 auth 的做法)。本仓库只给审批插件加同一个成员。 解锁 cloud#2549 最快,本仓库改动最小。但用 createHonoApp 嵌入的部署仍是 404,"这个地址由谁提供"的答案落在框架看不到的仓库里。
    C 做一个通用接口:每个内核暴露一张插件路由表,调度器在自身注册表之后查询它。 一次堵住整类问题(webhooks 的重投接口同属这一类,未实测),但推翻 ADR-0076 D11,需要新 ADR。任何插件都能声明路由并自选认证闸,绕过认证闸的风险最大。
    D 现在只补"响亮告警":内核缺 http.server 时打一行告警。 付费功能仍然坏着。托管环境每次启动都会打这行;等 A 或 B 落地后,这行告警反而不对了。

    业务含义: A 是"框架统一出口",B 是"云宿主私开小门",C 是"给所有插件发路由权",D 是"先挂个故障牌"。

    四轴(从业务看):

    • 实际业务需求: 今天碰到的是托管付费计划上每一封审批邮件(staging 已实测)。A 同时覆盖嵌入部署,B 只覆盖托管,C 的额外拉动未实测,D 解决不了问题。
    • 项目长远合理性: A 顺着托管形态已有的架构(调度器统一注册、路由桥接服务槽位,有 /auth 先例),把路由留在框架的路由表里。B 在宿主再养一张会和框架漂移的路由表。C 要推翻已定的 ADR。
    • 防 AI 犯错: 出错时谁看到什么?A 由 spec 声明成员,缺失时返回有类型的 404 或 501,是响亮拒绝。B 的路由在框架代理读不到的仓库里,框架对"谁提供这个地址"的回答是错的,属于静默错误。C 让任何插件都能自选认证闸,最差。
    • 创业阶段不扩散: A 加一个域和一个可选成员,不加门禁。认证闸白名单多一条,与自托管现状对齐,不是放宽。B 在本仓库改动最小。C 无拉动的扩面。

    os-decision-facets

    • ① 项目长远合理性:A 缩小特例,路由留在框架内;B 增生宿主侧特例;C 增生通用契约。
    • ② 实际业务拉动:托管付费计划的审批邮件(cloud#2549 实测);嵌入部署同样 404。
    • ③ 防 AI 犯错:A 有声明,缺失时响亮拒绝;B 的路由在框架之外;C 是自由结构。
    • ④ 创业阶段不扩散:A 只加一个可选成员和一个域;C 违反;D 不解决问题。

    Prior rulings read: approvals act dispatcher、registerDomainHandler plugin、ADR-0076 D11 → 本仓库内 ADR-0076 D11 锚点 1 处;thread: none。

    推荐:A,自托管保留现有原生挂载,字节不变。回退选项:B。只看①选 A;②③④ 是否翻转:否。②只影响分期:可以先交 A 的 runtime 和审批插件两段。
    置信缺口: 云宿主的兜底转发是否与 createHonoApp 同形,未实测(cloud 仓库本会话无读权限)。若不同形,A 落地后云侧还要配合一处转发。

    裁后执行(你只需裁方向):

    • 选 A: 本座位把本卡拆成四段:

      1. spec 段:声明审批服务成员,交 domain:spec;
      2. runtime 段:/approvals/act 域与 hono 兜底路由不吞表单体,交 domain:cli;
      3. core 段:认证闸白名单加这个路径,交 domain:engine,属于认证闸改动,按规矩在卡上写明与自托管对齐;
      4. 本车道段:审批插件成员与响亮缺失提示。

      各段 Blocked-by: 串联,cloud#2549 跟随框架的版本固定点更新解锁。

    • 选 B: 本车道只做审批插件成员这一段,云侧转发由 repo:cloud 座位落。

    • 选 D: 本车道只做告警一段,其余挂 pm:on-hold。

    相关: objectstack-ai/cloud#2549(消费方);开发顺带发现 plugin-webhooks 的 POST /api/v1/webhooks/redeliver 属于同一类(只挂在原生路由上),未复现,未立卡,随本裁决一并路由。

  5. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim released to the maintainer's decision · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-09T09:24Z

    • The dev's measurement round (os-dev-report above) stopped on the claim's condition: no surface inside plugin-approvals can add a route to a dispatcher-only kernel. Nothing was built and no PR was opened. The empty branch claude/issue-22438-approvals-act-dispatcher is the dev's write probe and carries no commit.
    • The decision analysis (in Chinese, per the decision-card convention) is the comment above. The card is now needs-user-decision, out of the active queue and unassigned.
    • After the ruling, this seat splits the card per the chosen option and re-claims its own lane's stage.

    Release: session_01WkL6Eijt432S1Y7ekb6ovQ (domain:services seat 1) · claim 6077875833 closed · measured, stopped at H2 · card → needs-user-decision.

  6. objectstack-fleet commented on Oct 9, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #302 item 1 · letter A + sweep · maintainer 「A + 扫类」 2026-10-09T11:06Z

    Director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (GitHub os-zhuang; written as objectstack-fleet[bot] via the relay). Presented in batch #302 from the domain:services seat 1's decision request (6078135782) on the dev's measurement (6078089627): A a runtime-owned /approvals/act domain bridging to the approvals service slot; B the cloud host proxies the path; C a generic per-kernel plugin route seam; D the loud warning only. The seat recommended A, and so did this seat. The maintainer then asked why a hosted tenant kernel has no http.server and whether the class is large; this seat measured the class and proposed A plus a sweep card (A-2) and a conformance pin (A-3). The maintainer answered 「A + 扫类」. Thread-read: 6078157153. Freshness: body unchanged; no comment since the presentation; labels bug, priority:p2, needs-user-decision, repo:cloud, domain:services, area:workflow. Premises re-read on origin/main 587bd9699d and cloud main ab2d61524e: approvals-plugin.ts:381–:382 mounts on http.server.getRawApp() and returns silently without it; packages/runtime/src/http-dispatcher.ts has no approvals domain; auth-plugin.ts:809 runs registerRoutes: false on a hosted kernel; ADR-0076 D11 (domain-handler-registry.ts:4–:10); auth-gate.ts:196 isAuthGateAllowlisted; the cloud host's inner app is createHonoApp({ kernel, prefix: '/api/v1' }) (apps/objectos/server/index.ts:357, :369), so A reaches the hosted shape with no cloud change, which closes the request's confidence gap; apps/objectos-ee/objectstack.config.ts:1496 says "per-env kernels have no http-server". The class, every non-test getRawApp() mount site classified by this seat: three producers reach a tenant kernel through the requires-driven loadCapabilities path — plugin-approvals' action pages (measured), plugin-webhooks' POST /api/v1/webhooks/redeliver (webhook-outbox-plugin.ts:389–:394, a silent return without a raw app), and trigger-api's inbound hooks POST (packages/triggers/trigger-api/src/plugin.ts:78, which warns "hooks endpoint not mounted"); the metadata HMR mount is development-only; the CLI console, cloud-connection, the Hono plugin itself, the verify harness and plugin-auth's wildcard mount are host-side or already bridged by the /auth domain.

    The ruling

    A, plus the class sweep.

    1. A as presented. packages/runtime gives the HttpDispatcher an exact /approvals/act domain for GET and POST that forwards the request to the request kernel's approvals service slot and returns its Response; an absent slot or member answers a typed 404 or 501, never ROUTE_NOT_FOUND (the loud half, acceptance 3). plugin-approvals adds one transport-neutral member that renders on GET and redeems on POST, and keeps its self-hosted raw-app mount byte-unchanged (no double mount). packages/spec declares the member, optional, on the approvals service contract (Clause-②: yes). The @objectstack/hono catch-all stops consuming non-JSON bodies. packages/core puts the act path on the ADR-0069 allow-list, so authentication stays token-only, aligned with self-hosted. cloud needs no change for the hosted shape (measured above); cloud#2549 unlocks when cloud's pin carries the fix.
    2. The sweep (A-2). One card the services seat files: on a dispatcher-only kernel, measure plugin-webhooks' redeliver endpoint and trigger-api's inbound hooks endpoint; each that answers 404 is bridged the same way (a dispatcher domain to the service slot, the same typed refusal, the same pins); the sweep report names any further member of the class it finds.

    ⛔ Not ruled now: the conformance pin (A-3), that every plugin a hosted plan can compose must serve its routes on a dispatcher-only kernel. It is a new gate, default no at this stage; the sweep report re-raises it with the measured class. ⛔ Not taken: B (a second route list in the host, and createHonoApp embedders stay broken), C (reverses ADR-0076 D11 and lets a plugin choose its own gate), D (the paid feature stays broken; the loud line belongs inside A's domain). Recorded for the thread, from the maintainer's question: a hosted tenant kernel owns no socket by design — one listener per host process, tenant kernels are in-process artifact kernels reached through createHonoApp and the HttpDispatcher — so getRawApp() is the self-hosted escape hatch, and the class is the three producers above, not a wide one.

    Prior rulings read: ADR-0076 D11 (dispatcher-owned registration); ADR-0069 (the auth-gate allow-list); ADR-0043 (token-only action pages); ADR-0004 (cloud multi-kernel); ADR-0049; triage 6077709759 (its plugin-auth lead, falsified by the dev's H2); cloud#2549 (the consumer). check-prior-rulings over 7 terms → 0 ADR hits, none ruling how a dispatcher-only kernel serves a plugin's pages; thread: 0 rulings of 5 comments. 自检: 只看①选 A+扫类;②③④ 是否翻转:否(④ 把 A-3 留为默认否,待扫类报告再提)。置信缺口:webhook redeliver 与 trigger-api hooks 在托管上未实测(扫类卡实测);带 HTTP 面的第三方插件数量未知。

    State

    • needs-user-decision → pm:queue (domain:services, priority:p2, area:workflow, repo:cloud) in this act; the Ruled: line added to the body. The services seat splits A into its four segments (spec, domain:spec; runtime and hono, domain:cli; the core allow-list, domain:engine; the plugin member, this lane), chained by Blocked-by:, and files the sweep card; cloud#2549 stays blocked on this card until cloud's pin moves. The empty branch claude/issue-22438-approvals-act-dispatcher may be reused or deleted.

    Generated by Claude Code

  7. added
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    and removed on Oct 9, 2026
  8. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Retriage ask: split ruling A into its four lane segments · domain:services seat 1 (#6021) · session_013j5gkUCpqQiti4GgPqqmnt · 2026-10-10T01:14Z

    ⛔ Not a claim. The ruling (6079645593, the maintainer's 「A + 扫类」) has this seat split A into four segments chained by Blocked-by:, and file the sweep card. Lane labels are triage's to write, so this card goes to pm:retriage. It keeps pm:queue until triage answers, and it is skipped for dispatch while it carries pm:retriage.

    Filed in this act: the sweep card A-2 as #22564, unlabelled, for triage's first grade.

    The ask (the answer this seat needs): file the four segment cards below with their lanes, and set this card's state.

    The segments, from the ruling's item 1. Every premise was re-read on origin/main 6a3f82efa7.

    1. spec → domain:spec. The approvals service contract declares one optional, transport-neutral member that renders the action page on GET and redeems it on POST (Clause-②: yes). Every other segment reads this member's shape, so this segment goes first.
    2. runtime and hono → domain:cli.
      • packages/runtime's HttpDispatcher gains an exact /approvals/act domain for GET and POST. It forwards to the request kernel's approvals service slot and returns its Response.
      • An absent slot or member answers a typed 404 or 501, never ROUTE_NOT_FOUND (acceptance 3). Today the dispatcher has no approvals domain: git grep -n -i approvals on http-dispatcher.ts gives 0 hits, against 71 for auth.
      • @objectstack/hono's catch-all stops consuming non-JSON bodies.
      • Blocked-by: segment 1.
    3. core → domain:engine. packages/core's ADR-0069 allow-list (auth-gate.ts, isAuthGateAllowlisted) takes the act path. Authentication stays token-only, as ADR-0043 sets it. That matches the self-hosted mount, so it is not a loosening. The card should say so.
    4. plugin member → domain:services. plugin-approvals implements segment 1's member. It keeps its self-hosted raw-app mount byte-unchanged (no double mount; approvals-plugin.ts:382 reads getRawApp() today). Blocked-by: segment 1.

    This card's state, as this seat proposes it: this card becomes the parent of the four segments, and it closes when the last of them is on main.

    • objectstack-ai/cloud#2549 is Blocked-by: this card and unlocks on cloud's pin move, so closing this card on one segment would unlock the consumer early.
    • So this card goes pm:blocked on the segments, and segment 4 is a separate card. If triage prefers that segment 4 rides on this card itself, the seat follows that answer.

    Branch: the empty branch claude/issue-22438-approvals-act-dispatcher has no commit of its own (it sits at da159f74, a main commit). The segment 4 claimant may reuse it or let it go.


    Generated by Claude Code

  9. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: ruling A split into its four lane segments. This card is now their parent, pm:blocked until the last one is on main

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-10T02:12Z. ⛔ Not a claim, ⛔ not a dispatch. Thread-read: 6092059305.

    Blocked-by: #22575, #22576, #22577, #22578

    Segment Card Lane State
    1. spec: the optional, transport-neutral action-page member (Clause-②: yes) #22575 domain:spec pm:queue, goes first
    2. runtime: the exact /approvals/act domain; hono's catch-all stops consuming non-JSON bodies #22576 domain:cli pm:blocked on #22575
    3. core: the exact act path on the ADR-0069 allow-list, token-only, as self-hosted #22577 domain:engine, security pm:queue, independent
    4. plugin-approvals: the member; the raw-app mount byte-unchanged #22578 domain:services pm:blocked on #22575
  10. added and removed
    pm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatch
    on Oct 10, 2026
  11. objectstack-fleet commented on Oct 10, 2026

    @objectstack-fleet
    ContributorAuthor

    Closed completed: all four segments of ruling A are on main. The consumer, cloud#2549, now waits on cloud's v18 pin

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-10T18:58Z. ⛔ Not a claim, ⛔ not a dispatch.

    Blocked-by: none

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3repo:cloud

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions