Skip to content

Commit 3986f9f

Browse files
docs(adr): ADR-0066 D4 cites isSameOriginApiTarget by symbol anchor, and the requiredPermissions docblock drops its door clause (#27965)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DYV6Kpbtvq8fY9nDbSbzkLe)_ Part of #27694 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:ADR-0066 D4 对 `isSameOriginApiTarget` 的裸名引用改成符号锚 `packages/runtime/src/action-execution.ts#isSameOriginApiTarget`,由 `check:adr-symbol-anchors` 守住;同时删掉那句「符号上 main 后由 #27694 改写」的待办。`requiredPermissions` 文档注释的「Landing」段删掉「门禁仍拒收 `type: 'api'`」这一条款,控制台条款保留。 - 为什么改:#27649 已合并,平台动作门禁现在会派发同源 `type: 'api'` 动作。随 `@objectstack/spec` 的 `.d.ts` 发出去的注释还说门禁拒收,这对作者和 agent 是错话;ADR 用裸名引用,代码改名也没有门禁会发现。 - 风险与回滚:两个文件 +5/−7,只动 ADR 正文和一段 JSDoc 注释,不改 `.describe()`、不改代码、不发布(`skip-changeset`);回滚即 revert 这一个提交。 - 先看:`packages/spec/src/ui/action.zod.ts` 的 `requiredPermissions` 注释末段(Landing 段),再看 `docs/adr/0066-unified-authorization-model.md` D4 第二段的那处引用。 - 两分钟验证:`pnpm check:adr-symbol-anchors`,预期 exit 0,计数为 611 个符号锚(585 个声明锚),比 main 上的 610 多一个,多出的就是这条锚,此后符号改名或挪走会转红。 - 需要你做的:暂无。contract review owed(契约复审未完成,席位复审后再求批);本 PR 改 ADR 与 `packages/spec/src`,落地为 Tier H,由受管批准人批准。 ## What changes Two files, +5 / −7, on base `ca54eaca`. - **`docs/adr/0066-unified-authorization-model.md`, D4 "Scope of the server half".** The same-origin definition cited the predicate by bare name, "as the server card's `isSameOriginApiTarget` judges it", followed by a parenthetical that scheduled this very rewrite ("once the symbol is on `main`, card #27694 rewrites this citation into a symbol anchor…"). The citation is now the symbol anchor `packages/runtime/src/action-execution.ts#isSameOriginApiTarget`, and the scheduling clause is gone. The rest of the parenthetical stays: "PR #27649; the door holds the interpolated path to the same test before it forwards". - **`packages/spec/src/ui/action.zod.ts`, the `requiredPermissions` docblock, paragraph "Landing, as of 2026-10-29".** The door clause goes: "Until it lands the door still refuses `type: 'api'` (`isHeadlessInvokableAction` admits `script`, `flow` and the declarative update only)". It is false since #27649. The paragraph now says the server half (step 2, #27506) is on `main` (#27649). The console clause stays, because #27611 is still open: "Until step 3 (#27611) lands the console fetches `target` directly; card #27694 removes this paragraph when it does." No `.describe()` changes, no code, no generated file. ## Why - **The door clause is false on `main`.** `isHeadlessInvokableAction` admits `type: 'api'` when `isSameOriginApiTarget(action?.target)` holds, and `headlessActionTypeError` answers `null` for a same-origin api action. Both are in `packages/runtime/src/action-execution.ts`, and #27649 (`481ade61c`, an ancestor of the base) put them there. The docblock ships in `@objectstack/spec`'s `.d.ts`, so authors and agents were told the door refuses a call it now runs. - **The console clause is still true.** `apiHandler` in `ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx` still `authFetch`es the resolved `target`, and #27611 is open. - **The ADR prose around the anchor still reads true.** `isSameOriginApiTarget` returns `false` for a non-string, for a string shorter than 2, for one not starting with `/`, and when the second character is `/` or a backslash. That is the ADR's "starts with exactly one `/` and goes on past it (`/` alone, `//host` and `/\host` are not)". The forward re-tests the interpolated path (`if (!isSameOriginApiTarget(path))` in `buildApiActionRequest`, which assembles the request the forward sends), which is "the door holds the interpolated path to the same test before it forwards". ## Deviations from the dispatch order - **The Landing paragraph is reworded around the deleted clause, not only cut.** Its door and console clauses shared one sentence ("Until it lands the door still refuses … and the console fetches `target` directly"), where "it" was the server half. Deleting the door half alone would leave "Until it [the server half] lands the console fetches `target` directly". That is false: the server half has landed and the console still fetches. The last sentence scheduled two removals, and one of them is this PR. So the paragraph keeps the console clause, ties it to step 3, and keeps the pointer to #27694 for the second pass. The date "as of 2026-10-29" is kept: #27649 merged 2026-10-29T21:32Z, so every statement in the paragraph was already true on that date. - **No dated amendment note in the ADR.** House form, measured: 53 ADRs carry `path#symbol` anchors after the #13556 line→symbol migration, and only one carries an "Anchor note" (ADR-0113). That note exists because its re-anchoring dropped to file level and the row it anchors is stale. ADR-0097's and ADR-0099's dated notes record changes of meaning. This rewrite changes no meaning. It also carries out a step the 2026-10-29 amendment itself scheduled, under the paragraph's existing "api clause amended 2026-10-29, Ruling-ref: 6499781492" label. So it follows the no-note precedent of the anchor migration. ## Acceptance notes These go stale with #27649 but are outside this card's file surface or its ruling, so they are left as is: - ADR-0066's **Status line** still ends "The server half lands under #27506; see the note under D4." It is present tense, and the server half has landed (#27649). - The D4 **amendment note** (dated 2026-10-29) ends "Until step 2 lands the door still refuses `type: 'api'` … so no agent can invoke a `type: 'api'` action today". It is a dated record and its condition ("until step 2 lands") is spent. It is not edited: an amendment note is a record of its date. Carrier: #27694's second pass, which the maintainer can widen if wanted. ## Verification All readings are on head `701d5499` (base `ca54eaca`). <details><summary>dispatch-gates: 74 derived families, 74 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands` was run with no paths, on the committed diff against merge base `ca54eaca`. It derives 74 families, and all 74 were run with exit codes captured before any pipe. Prerequisites were `pnpm install --frozen-lockfile --ignore-scripts` and `turbo run build --filter=@objectstack/spec --filter=@objectstack/formula --filter=@objectstack/lint`. Two families first exited 3 (PREREQUISITE NOT MET: `check:dual-build-cjs-loads`, `check:lean-entry-closure`, both needing built dist outside spec). After a whole-tree `turbo run build` (lock VERDICT command-exit 0), both exited 0: 113 published require entry points across 70 packages load, and the objectql/core admitted set holds exactly. `check:dts-closure`, `check:sourcemap-no-sources-content` and `check:published-files` were re-run over the whole built tree: exit 0, 115 / 108 / 74 packages swept. `--ran`: `74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED`. Re-derived after the run, the list was identical. The ADR gates in the list: `check:adr-symbol-anchors`, `check:adr-anchors`, `check:adr-index`, `check-adr-links.mjs` (+ `--self-test`), `check-adr-0087-registration.mjs`, all exit 0. `check:adr-symbol-anchors` counts 611 symbol anchors (585 declaration) and 2161 file-level on this head, against 610 (584) and 2162 on `ca54eaca`. The bare-path citation of `action-execution.ts` became a declaration anchor, so the new anchor is in the gated population. Spec-docblock gates: `check:spec-docblock-symbol-anchors`, `check:docs`, `check:api-surface`, `check:authorable-surface`, all exit 0. `check:nul-bytes` exit 0. </details> <details><summary>@objectstack/spec typecheck and tests</summary> - `pnpm --filter @objectstack/spec run typecheck`: lock VERDICT command-exit 0. - `pnpm --filter @objectstack/spec run test`: `Test Files 756 passed (756)`, `Tests 22481 passed | 1 todo (22482)`, lock VERDICT command-exit 0. - No ablation: the diff changes prose and a comment, not tested behaviour. </details> `git merge-tree` against `origin/main` `bacb3e6a` is clean, and no commit since the base touched either file. `--tier` answers MANDATORY `claude-fable-5-1` for `docs/adr/**`. The claim records the proactive low-headroom downgrade, compensated by the contract-tier review. Changed lines: 12 (+5 / −7), under the 3000 threshold. Contract review owed: governed `docs/adr/**` and `packages/spec/src/**`, at `CONTRACT_REVIEW_TIER`. That review also answers the Clause-② path leg on `packages/spec/src/**`. Landing is Tier H. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (a JSDoc comment only) - [x] 2. Every name I added uses the casing for its kind. (no names added) - [x] 3. No formatter ran; `pnpm lint` passes (prose only; the full run is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset. It changes a comment only, no `.describe()` and no code, so `skip-changeset`. Both declaration lines are above. - [x] 6. `packages/spec` touched: nothing to regenerate. The change is a JSDoc comment. After the spec build and all 74 gate runs, the tree was clean (`git status` empty), and `check:docs` exited 0. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 is the sanctioned Chinese channel). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4d1LtiXudcBaRgY2xRg)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b6db369 commit 3986f9f

2 files changed

Lines changed: 5 additions & 7 deletions

File tree

‎docs/adr/0066-unified-authorization-model.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Extend `requiredPermissions` (today only on `App`/nav, **[existing]**) to **Obje
7171
### D4 — Dual-surface action gates [new]
7272
An action declaring `requiredPermissions` is enforced in **one place, two surfaces**: the ActionRunner hides/disables it in the UI **and** the server rejects the call when the caller lacks the capability. Removes the "UI-gated but server-open" footgun (and the inverse). Server enforcement is the source of truth; UI gating is derived from the same declaration.
7373

74-
**Scope of the server half (clarified 2026-07, framework#3923; api clause amended 2026-10-29, Ruling-ref: 6499781492).** "The server rejects the call" means the PLATFORM ACTION DOOR — `POST /api/v1/actions/<object>/<action>` and the MCP `run_action` bridge, which enforce one gate (`packages/runtime/src/action-execution.ts#actionPermissionError`) and dispatch `type: 'script'`, `type: 'flow'` and the declarative `operation: 'update'` write. A `type: 'api'` action whose `target` is a same-origin platform path — the declared `target`, before interpolation, starts with exactly one `/` and goes on past it (`/` alone, `//host` and `/\host` are not), as the server card's `isSameOriginApiTarget` judges it (PR #27649; once the symbol is on `main`, card #27694 rewrites this citation into a symbol anchor on `isSameOriginApiTarget` in `packages/runtime/src/action-execution.ts`; the door holds the interpolated path to the same test before it forwards) — is dispatched by that door too: the route and the bridge run the anonymous baseline, this D4 gate and the ADR-0126 §8 activation consult, then forward the call in-process to the declared `target` as the caller — the request assembled from the declaration (`method`, `recordIdParam`, `bodyShape`, `bodyExtra`, the placeholders in `target`), the caller's cookies and auth headers carried. The target's `Set-Cookie` is returned on the REST route's response; the MCP bridge relays only the bearer-lane rotation headers (`set-auth-token`, `set-admin-session-token`), and no cookie value enters a tool result. The door gates the calls it dispatches, and only those: the `target` endpoint stays directly reachable — nothing routes a direct call to it through the door — so it MUST still enforce the declared capability itself, and the door's gate is the added check: defence in depth, not a replacement. An api action whose `target` is not a same-origin platform path stays browser-only and ungoverned: the door does not dispatch it, the MCP bridge does not list it, and the activation write door (`POST /api/v1/actions/_activation/<object>/<action>`, `packages/runtime/src/domains/actions.ts#handleActionActivationWrite`) refuses to flip it, with a reason, rather than record a switch nothing consults. The UI half is correspondingly *every* action surface, not just the engine: `record_header`/`record_more` (`page:header`) and `list_item` (the grid row menu) filter their own action lists and were fixed to apply the same gate (objectui), alongside `ActionEngine.getActionsForLocation`. All of them fail OPEN when the client does not know the caller's capabilities — unknown is not denied.
74+
**Scope of the server half (clarified 2026-07, framework#3923; api clause amended 2026-10-29, Ruling-ref: 6499781492).** "The server rejects the call" means the PLATFORM ACTION DOOR — `POST /api/v1/actions/<object>/<action>` and the MCP `run_action` bridge, which enforce one gate (`packages/runtime/src/action-execution.ts#actionPermissionError`) and dispatch `type: 'script'`, `type: 'flow'` and the declarative `operation: 'update'` write. A `type: 'api'` action whose `target` is a same-origin platform path — the declared `target`, before interpolation, starts with exactly one `/` and goes on past it (`/` alone, `//host` and `/\host` are not), as `packages/runtime/src/action-execution.ts#isSameOriginApiTarget` judges it (PR #27649; the door holds the interpolated path to the same test before it forwards) — is dispatched by that door too: the route and the bridge run the anonymous baseline, this D4 gate and the ADR-0126 §8 activation consult, then forward the call in-process to the declared `target` as the caller — the request assembled from the declaration (`method`, `recordIdParam`, `bodyShape`, `bodyExtra`, the placeholders in `target`), the caller's cookies and auth headers carried. The target's `Set-Cookie` is returned on the REST route's response; the MCP bridge relays only the bearer-lane rotation headers (`set-auth-token`, `set-admin-session-token`), and no cookie value enters a tool result. The door gates the calls it dispatches, and only those: the `target` endpoint stays directly reachable — nothing routes a direct call to it through the door — so it MUST still enforce the declared capability itself, and the door's gate is the added check: defence in depth, not a replacement. An api action whose `target` is not a same-origin platform path stays browser-only and ungoverned: the door does not dispatch it, the MCP bridge does not list it, and the activation write door (`POST /api/v1/actions/_activation/<object>/<action>`, `packages/runtime/src/domains/actions.ts#handleActionActivationWrite`) refuses to flip it, with a reason, rather than record a switch nothing consults. The UI half is correspondingly *every* action surface, not just the engine: `record_header`/`record_more` (`page:header`) and `list_item` (the grid row menu) filter their own action lists and were fixed to apply the same gate (objectui), alongside `ActionEngine.getActionsForLocation`. All of them fail OPEN when the client does not know the caller's capabilities — unknown is not denied.
7575

7676
> **Amended (2026-10-29) — the api clause above reverses the browser-only half of the 2026-07 clarification (ruling of record 6499781492 on #27506; Prime Directive #13: a recorded decision is reversed by an ADR edit, never by a changeset).** As clarified on 2026-07 (framework#3923) this paragraph read: "A `type: 'api'` action whose `target` is a self-authored endpoint is fetched by the browser directly and never passes through that route, so the declaration has no server-side consumer there; that endpoint must re-check the capability itself." Its first half — fetched by the browser directly, never passes through that route, no server-side consumer — recorded the gap as the rule and is superseded, not deleted. Its last clause stands: the endpoint must still re-check the capability itself, now beside the door's gate (the paragraph above). Under the maintainer's design goal, a capability a human reaches by clicking and an agent cannot reach through the action surface is a defect, and the unhonoured ADR-0126 switch on a `type: 'api'` action is one symptom of it. The maintainer, verbatim and untranslated: 「我们的平台设计目标是 agent 和人类使用同一套系统」 / 「同意」. The paragraph above also drops `modal` from the dispatched types: the door answers a `modal` action 400 as a client-side type (`packages/runtime/src/action-execution.ts#headlessActionTypeError`), and it has since gained the declarative update. **Landing, in three steps.** This record is step 1. Step 2 is the server card #27506: the door and the bridge accept a declared same-origin `type: 'api'` action, `packages/runtime/src/action-execution.ts#isHeadlessInvokableAction` admits it (and the `list_actions` filter in `packages/runtime/src/domains/mcp.ts#buildMcpBridge` is widened beside it), and the activation write door refuses an off-origin one. That does not put every api action in front of an agent through MCP: `list_actions` and `run_action` keep the `ai.exposed` opt-in and the fail-closed `sys_*` guard (`packages/runtime/src/domains/mcp.ts#buildMcpBridge` for the listing, `packages/runtime/src/action-execution.ts#invokeBusinessAction` for the call, both through `packages/runtime/src/action-execution.ts#isSystemObjectName`), so `sys_user.ban_user` and the other api actions on platform `sys_*` objects stay off MCP and reach an agent through REST `POST /api/v1/actions/<object>/<action>` — a gap this amendment records and does not close; card #27681 carries it. Step 3 is the ui card #27611: the console's api handlers send a same-origin click to the door instead of fetching `target`. Until step 2 lands the door still refuses `type: 'api'` — `packages/runtime/src/action-execution.ts#isHeadlessInvokableAction` admits `script`, `flow` and the declarative update only, `packages/runtime/src/action-execution.ts#SERVER_DISPATCHED_ACTION_TYPES` is `script`, `flow`, and `packages/runtime/src/action-execution.ts#headlessActionTypeError` answers an api action 400 — so no agent can invoke a `type: 'api'` action today, and the console still fetches `target` directly.
7777

‎packages/spec/src/ui/action.zod.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,12 +1611,10 @@ const actionObject = () => strictObject({
16111611
* activation switch refuses to flip it, and nothing checks this declaration
16121612
* server-side.
16131613
*
1614-
* Landing, as of 2026-10-29: ADR-0066 records the decision; the server half
1615-
* is the ruling's step 2 (#27506). Until it lands the door still refuses
1616-
* `type: 'api'` (`isHeadlessInvokableAction` admits `script`, `flow` and the
1617-
* declarative update only) and the console fetches `target` directly. Once
1618-
* the server half (#27649) lands, card #27694 removes this paragraph's door
1619-
* clause; its console clause goes when step 3 (#27611) lands.
1614+
* Landing, as of 2026-10-29: ADR-0066 records the decision; the server half,
1615+
* the ruling's step 2 (#27506), is on `main` (#27649). Until step 3 (#27611)
1616+
* lands the console fetches `target` directly; card #27694 removes this
1617+
* paragraph when it does.
16201618
*/
16211619
requiredPermissions: z.array(z.string()).optional().describe('[ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door\'s gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side.'),
16221620

0 commit comments

Comments
 (0)