Skip to content

capability-declaration-lifecycle: no shipped resource enforces a package capability server-side (no REST action-invoke route on this build) #7680

Closed

Description

@huangyiirene

Symptom

No shipped resource enforces a package capability server-side. The three showcase actions carrying requiredPermissions have no REST execution route on this build: /api/v1/actions/<n>, /api/v1/action/<n>, /api/v1/objects/<o>/actions/<n>, and /api/v1/data/<o>/actions/<n> all 404, and rest-route-ledger.ts registers no action-invoke route. So the requiredPermissions gate for these actions is client-side only; there is no server surface on which to exercise the grant/deny contrast.

The three-way grant/deny/regrant resolves by name, but only demonstrable with a platform capability (manage_metadata). The remainder of the item passes: declaration → sys_capability seeding is exact (both documented fallbacks — humanize(name) label, generated description); platform-capability hijack is refused (a package declaring manage_users gets a WARN, skippedPlatform:1, the platform row unchanged — though the refusal is a WARN and boot proceeds); malformed declarations abort boot at Zod parse before any sys_capability write, so #5961 does not hold.

Root cause

Located by the run at packages/rest/src/rest-route-ledger.ts: the ledger's data-actions family registers clone/import/export routes but no action-invoke route for declared object/record actions. Confirmed on origin/main — the data-actions block lists only clone / import / import-jobs / export, no …/actions/<name> invoke route.

Related prior art (closed, different framing): #3923 reported Action.requiredPermissions as client-side fail-open in Console for type:'api' actions on 17.0.0-rc.0, and noted the server-side actionPermissionError covers only the platform ql.executeAction / MCP paths. This card records the coverage gap as observed on build 92f26f7: for the shipped showcase actions there is no REST invoke route at all, so capability-declaration-lifecycle clause 1 can only be exercised against a platform capability.

Reproduction

  1. Stock showcase boot; enumerate the REST route ledger and probe the four action-invoke shapes above for a showcase action declaring requiredPermissions → all 404.
  2. Confirm the grant/deny/regrant contrast is only demonstrable via a platform capability (manage_metadata).

Suggested fix

Either ship a server-side action-invoke route that enforces requiredPermissions for package-declared actions (so the gate is not client-side only), or record explicitly that package capability enforcement is not yet a server surface and adjust the checklist item's expectation. Route owner: packages/rest route ledger unless the action-invoke owner is located elsewhere.

Source

Extracted from the QA run #7637 (framework 92f26f7, console 09987b6).

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 11, 2026
  2. removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:queueReady for the PM dispatch loop
    on Aug 11, 2026
  3. claude commented on Aug 11, 2026

    @claude
    Contributor

    Dual-label resolution (queue+finding): reclassified needs-user-decision (both pm:queue and finding stripped; domain:cli stays as routing). The card's own fork is a public-API surface ruling: ship a REST action-invoke route that enforces package requiredPermissions server-side, or record that package capability enforcement is deliberately not a REST surface. Verified on origin/main @ 6a9dec6: packages/rest/src/rest-route-ledger.ts:221-230 registers only clone / import / import-jobs / export in the data-actions family — no action-invoke route, as filed. Prior art #3923 (closed completed) fixed the console-side UI gating only and did not rule on a server invoke surface, so this is not an already-made decision. Fail-closed today (all four probe shapes 404), so not a target:v17 blocker on the four-class test.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 11, 2026
  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Maintainer ruling — 2026-08-12

    裁定:记录口径,不建路由。

    要点:

    • 口径记录:包能力(package capability)的服务端强制点是 ql.executeAction / MCP 路径的 actionPermissionError;本构建的 REST 没有 action-invoke 面(四种拼法 404、ledger 无此路由),所以「REST 上的 requiredPermissions 强制」不是缺陷,是不存在的面。把这段口径写进 checklist 该条目的预期(measure 真实存在的面:平台路径的 grant/deny/regrant),并在 rest-route-ledger.ts 的 data-actions 块注释里说明 invoke 路由是有意缺席。
    • ⛔ 现在不上 REST action-invoke 路由:没有实测需求为它背书。记录在案:当它因真实需求上线时,requiredPermissions 的服务端强制是 day-one 要求,不是 follow-up —— 否则就是 Action.requiredPermissions 在 Console 端形同虚设:会话不下发 systemPermissions + 客户端 fail-open(17.0.0-rc.0) #3923 报过的 client-side fail-open 复活。
    • 顺带确认卡内旁枝:平台能力劫持拒绝(WARN + skippedPlatform)与 boot 期 Zod 硬拒都按预期,不动。

    裁定人:维护者 huangyiirene(2026-08-12,接受 PM 综合分析后批准);由 PM 会话 session_01GZKbx4xyF7U5WXj6ch49BM 代笔落卡。转 pm:queue(注释 + checklist 口径卡,小)。


    Generated by Claude Code

  6. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 12, 2026
  7. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  8. self-assigned this
    on Aug 12, 2026
  9. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claim: PM loop round 3 — domain:cli seat.

    Dispatched to execute the maintainer's 2026-08-12 ruling (comment 5261755580) as written — ⛔ not re-opened, not re-litigated. The dispatch carries the ruling verbatim and untranslated, since rewriting a ruling is rewriting the decision.

    Two things I am explicitly holding the dev to, because they are the ways this card could quietly go wrong:

    1. ⛔ No REST action-invoke route. The ruling declines to build one. A dev that "helpfully" adds the route has inverted the decision.
    2. The checklist item must be re-pointed at a surface that exists, not merely relaxed. The ruling names it: measure grant/deny/regrant on the platform path (ql.executeAction / MCP actionPermissionError). Deleting the clause, or softening it to something unfalsifiable, is not the fix — the item should still fail if that enforcement breaks.

    The day-one requirement recorded in the ruling (if a REST action-invoke route ever ships for real demand, server-side requiredPermissions enforcement is day-one, not a follow-up) belongs in the ledger comment, so the next author of that route reads it there rather than rediscovering #3923's client-side fail-open.


    Generated by Claude Code

  10. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor
    {
      "issue": 7680,
      "status": "done",
      "branch": "claude/issue-7680-package-capability-doctrine",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7913",
      "premise_still_valid": true,
      "summary": "Executed the 2026-08-12 ruling as written — recorded the doctrine, built no route. Premise re-verified on origin/main e3a6f6e by text search, not line number: the data-actions family still registers only clone / import / import-jobs / export, and none of the four probe spellings exist anywhere in packages/rest/src (the only `actions`-shaped REST row is GET /api/v1/approvals/requests/:id/actions, an approvals audit trail). Enforcement point located at packages/runtime/src/action-execution.ts:383 (actionPermissionError, ADR-0066 D4), reached from runtime/src/domains/actions.ts (dispatcher /actions -> ql.executeAction) and runtime/src/domains/mcp.ts:433 (MCP run_action). TWO DELIVERABLES, nothing else. (1) rest-route-ledger.ts data-actions block: 36 added lines, ALL comment — zero code lines changed — stating the invoke route's absence is deliberate, naming the platform paths that DO enforce requiredPermissions and where they are ledgered (packages/runtime/src/route-ledger.ts, not this file), and carrying the ruling's day-one clause for the next author of that route. It also disambiguates a live trap: actionPermissionError's own docstring says it is single-sourced 'so the REST /actions/... route and the MCP run_action bridge enforce the SAME declaration' — that 'REST' is the DISPATCHER's HTTP surface, not a route @objectstack/rest mounts, and read without the split it reads as a promise this package already keeps. (2) access-security.capability-declaration-lifecycle rev 1 -> 2: REQUIRE clause re-pointed at the platform action path with all three legs (grant / deny / regrant), NOT deleted and NOT softened. ONE CORRECTION TO THE ISSUE'S FRAMING, now recorded in the item: the issue reads as though nothing requires a package capability and the old knownGap said so outright; four shipped type:'script' actions do (showcase_zoo_perm_held / _missing / _and / _empty in examples/app-showcase/src/ui/actions/predicate-matrix.action.ts, on the zoo object), so the platform path can invoke them. What they lack is a REST door, not a requiring resource — which is why the re-pointed clause measures real shipped fixtures instead of the manage_metadata fallback the QA run was forced into.",
      "tests": {
        "behaviour_pinned": false,
        "why": "Comment + checklist-prose card. The TS diff is comment lines only (git diff filtered for non-comment +/- lines in rest-route-ledger.ts returns empty), so there is no behaviour to pin and no test was invented to pretend otherwise. The falsifiability requirement was met in the checklist item's own `negative` array instead — see failure_modes below.",
        "gates_run": [
          "node scripts/pm/dispatch-gates.mjs packages/rest/src/rest-route-ledger.ts docs/qa/platform-checklist/areas/access-security.json -> matched check:authz-resolver, check:meta-type-normalized",
          "pnpm check:authz-resolver -> PASS (self-test + real-repo positive control)",
          "pnpm check:meta-type-normalized -> PASS (19 files)",
          "pnpm check:nul-bytes -> PASS (7236 files)",
          "pnpm check:platform-checklist -> PASS (15 areas, 182 items) — this is the gate that enforces revision <-> last-history-entry, which the rev-2 bump had to satisfy",
          "pnpm --filter '@objectstack/rest...' build -> PASS incl. DTS (a bare --filter @objectstack/rest build fails on unbuilt deps; deps built first, per 'build before trusting any typecheck')",
          "packages/rest test suite -> PASS, 92 files / 1477 tests (incl. rest-route-ledger.conformance.test.ts)"
        ],
        "not_run": "lint.yml's full farm — left to CI once, per dispatch.",
        "failure_modes_of_the_repointed_clause": [
          "FAIL-OPEN: a non-granted member's platform-path invocation of showcase_zoo_perm_held SUCCEEDS -> red. Also red: showcase_zoo_perm_missing executing for anyone (showcase.restricted_ops is granted to nobody), or showcase_zoo_perm_and executing for a caller holding only showcase.export_data (the gate is an AND, not an OR).",
          "STALE GRANT: after showcase.export_data is withdrawn from OpsPermissionSet, the former holder still executes -> red.",
          "OVER-BLOCK: the OpsPermissionSet holder is refused, or showcase_zoo_perm_empty is refused for anyone -> red. 'Declares nothing' is not 'denies everything'.",
          "MIS-RUN: a REST action-invoke 404 scored as pass or fail -> the item says neither, and to re-run on the platform surface.",
          "Concretely: make actionPermissionError return null unconditionally and legs 1 + 2 go red. That is the property the item pins."
        ]
      },
      "open_questions": [
        "RESOLVED BY ME, recorded rather than asked: which platform surface the runner should drive. @objectstack/rest mounts neither /actions nor /mcp, so on a REST-fronted showcase build the dispatcher's HTTP /actions surface is not reachable either — which is exactly why the QA run measured 404s. Pinning the clause to one named HTTP route would have made it unrunnable again, in the same way as the clause it replaces. The item therefore instructs the runner to drive whichever of {dispatcher /actions domain, MCP run_action bridge, in-process ql.executeAction/invokeBusinessAction} the build under test exposes and to RECORD which; all three funnel through the single-sourced actionPermissionError, so the gate under measurement is identical either way. Recorded as a knownGap, not left implicit.",
        "NOTED, no action taken (out of the ruling's scope): the MCP run_action bridge is a weaker vehicle for this clause than it first appears — runtime/src/domains/mcp.ts gates on actionAiExposureError BEFORE actionPermissionError, and none of the four predicate-matrix specimens declare ai.exposed, so on the MCP path they are filtered out for AI-exposure reasons before the capability gate is ever consulted. A runner who picks MCP and sees 'not listed' must not read that as a capability refusal. The item's evidence field requires naming the surface driven, which surfaces this; the ruling names both paths, so I recorded the nuance rather than narrowing what the ruling said."
      ],
      "out_of_scope_findings": [],
      "scope_discipline": {
        "no_rest_action_invoke_route_added": true,
        "platform_hijack_refusal_and_boot_zod_untouched": true,
        "files_changed": [
          "packages/rest/src/rest-route-ledger.ts (comment only, +36/-0)",
          "docs/qa/platform-checklist/areas/access-security.json (+44/-12)"
        ],
        "forbidden_paths_untouched": [
          "packages/rest/src/rest-server.ts",
          "packages/runtime/src/domains/**",
          "packages/runtime/src/sandbox/**",
          "packages/mcp/**",
          "content/docs/releases/**"
        ],
        "changeset": "none — a comment/prose diff releases nothing. Needs the `skip-changeset` label; an empty changeset would strand the release. LABEL NOT YET APPLIED BY ME — flagged for the PM if my label write does not land."
      }
    }

    Generated by Claude Code

  11. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    ACCEPTED — PR #7913. Landing on gate-green.

    Reviewed by the domain:cli seat (hotlong, session session_01B3Kurx8qufrDzNjk4rag7V) against the diff. Exactly the declared surface: packages/rest/src/rest-route-ledger.ts + the capability-declaration-lifecycle item in docs/qa/platform-checklist/. ⛔ No REST action-invoke route was added — the ruling's central instruction, and the one way this card could have inverted itself.

    It refuted a premise in the card rather than coding around it

    The checklist item's own knownGap said "no shipped resource REQUIREs showcase.export_data yet". That is false, and the dev measured it: examples/app-showcase/src/ui/actions/predicate-matrix.action.ts ships four REQUIRE-side specimens — perm_held (the granted capability), perm_missing (defined, granted to nobody), perm_and (requires both — the gate is an AND), perm_empty (requires nothing). All four are type:'script' on zoo, so the platform action path can invoke them.

    That changes the outcome. I dispatched this expecting "re-point the clause at a surface that exists"; what landed is a clause that is more falsifiable than the one it replaces — grant → deny → regrant as three separate legs, plus the two cross-checks that catch the gate being an OR or being blanket. I explicitly warned against a clause "softened into something unfalsifiable"; this went the other way.

    The failure modes are now named, which is what makes it a gate

    negative previously carried only the shadow-declaration and #5961 cases. It now spells out: fail-open (a non-granted member executing perm_held — the server-side shape of #3923's client-side fail-open), stale-grant (the former holder still executing after the grant is withdrawn — the gate reading a cached grant), over-block (the holder refused, or perm_empty refused for anyone — "declares nothing" is not "denies everything"), and AND-vs-OR (perm_and executing for a caller holding only one).

    And the one I most wanted: a REST 404 scores NOTHING, in either direction — scoring the clause off it is a mis-run. That is the precise mistake this card was filed from, now written where the next runner will read it.

    The ledger comment does the job the ruling assigned it

    It states the absence is deliberate, names where enforcement actually lives (actionPermissionError, packages/runtime/src/action-execution.ts, reached via the dispatcher /actions domain, the MCP run_action bridge, or ql.executeAction), and disambiguates a real trap: actionPermissionError's own docstring says "REST /actions/... route", which means the dispatcher's HTTP surface, not one @objectstack/rest mounts.

    The forward-looking clause is addressed to the future author of that route and carries the reasoning, not just the rule: a REST invoke door shipping without the same gate resurrects #3923 — "the action greyed out in the UI and wide open on the wire, which is the worst of both, because the UI's refusal reads as proof the rule is being kept." It also says to call actionPermissionError rather than re-derive it, since a second implementation is a second thing to drift.

    Revision history updated to revision: 2 with the ruling referenced. skip-changeset is right for a comment + checklist diff.

    ⛔ Not flipped yet: ESLint and TypeScript Type Check must both read conclusion: success on the head commit first.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions