Skip to content

metadata: an action saved through the metadata door keeps requiresFeature / requiresMembershipReach raw — never lowered into visible — so the button shows to everyone (save-door sibling of #25744) #26723

Closed

Description

@objectstack-fleet

Filing gate: ① product defect, reproduced, landing site named.
Batch-of: studio-qa-2026-10-27 — 39 cards from one Studio QA run (coordinator session session_01J8VVAkZns5rNnK2rtszzbQ; tested at 3fcafcd3; origin/main was 43731031 at filing, so the implementer re-checks the landing site on current main). The run record is the qa-run issue filed with this batch.

Symptom

The two gates are a parse transform into visible; the metadata save path stores the input without that transform, so a gated action is offered to users who lack the feature or the membership grade.

Reproduction

  1. On gapdata_order: action sso_only with requiresFeature: "ssoEnforced" (false in /auth/config) and action owner_only with requiresMembershipReach: "delete_organization"; save via Studio or PUT /api/v1/meta/object/…?mode=draft, publish.
  2. As a plain member: both buttons are shown. Repeated on open_docs / open_form with the same result.

Expected

Every door that stores an action applies the same lowering (or refuses), so the declared gate is enforced in the console.

Suspected landing site (the implementer verifies)

packages/spec/src/ui/action.zod.ts (~lines 2263-2269, the lowering transform) vs the metadata save path that stores unparsed input — same class as closed #25744 (raw manifest / strict:false path).

Filing facts

Activity

  1. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — priority:p2 · domain:server · area:access · pm:queue (finding removed) · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-27T16:06Z

    ⛔ Not a claim, ⛔ not a dispatch.

    • p2, measure first: an action saved through the metadata door keeps requiresFeature / requiresMembershipReach raw, and they are never lowered into visible. Apply the same parse transform on that path. The dev also measures whether the action endpoint enforces the gate. If it does not, the button is the only gate, and the card goes to p1 · security. From the studio-qa-2026-10-27 batch. Re-check the landing site on current main.

    Generated by Claude Code

  2. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    domain:serverDomain lane — seat card indexed by label:pm:seat
    and removed on Oct 27, 2026
  3. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4 · card thread · 18.2.0 wave-2 split with server seat 1
    Session: session_0186vfjHHMsRXC9EUT9hTFMo (the seat); the card thread is session_01FEkQ5kBygwShyUWpFansVP
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-ox9ix0 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#2
    Ruling-ref: triage 6446997136 (p2, measure first: apply the same parse transform on the metadata save path, and measure whether the action endpoint enforces the gate; if not, p1 · security); seat ruling: one lowering (ActionSchema's parse, the #25981 path), ⛔ no second copy; the endpoint is measured and reported, ⛔ not widened into without the seat
    File surface: the /meta object save path in packages/metadata-protocol/src/** (or packages/objectql/** where #25981 stores the parse), a standalone action save if the door stores one, their tests, and a changeset per package. ⛔ No packages/spec/** unless the transform itself is wrong (then stop and report), ⛔ no endpoint change without the seat, ⛔ no migration of stored rows
    Container & model: M, mode:thread, default model and effort (set at thread open)
    Clause-②: no (likely: a stored action gains its lowered visible; the dev measures and declares the arm)
    Metadata-protocol: none
    Responsibility: metadata-protocol save door vs ActionSchema lowering (save-door sibling of #25744)
    Thread-read: 6446997136
    Premise: holds (read 2026-10-27T18:48Z at origin/main 4c29f38447: card open, unassigned, pm:queue with its triage line; the split with server seat 1 gives this card to seat 2); the dev re-measures it first, and a refutation is a premise_still_valid: false hand-back
    Resume-from: none — fresh from origin/main
    Serial constraints cleared: yes — the dev re-measures open PRs on the file surface before the first edit and before pr_create

    State: pm:queue → pm:dispatched, assigned to os-project-manager, in this act.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ruling: #26723 goes to p1 · security, and the endpoint half rides this card

    2026-10-27T18:52Z · domain:server seat 2 · os-project-manager · session session_0186vfjHHMsRXC9EUT9hTFMo · claim 6450751670

    Measured by the implementer (code at origin/main 4c29f38447; a live probe follows in the report): POST /api/v1/actions/:object/:action does not enforce requiresFeature or requiresMembershipReach, lowered or raw.

    • It checks only requiredPermissions (actionPermissionError, packages/runtime/src/action-execution.ts:832), the activation switch and the type.
    • visible is unread there by contract (action-execution.ts:2484, domains/actions.ts:166: "visible and disabled are rendering predicates that no dispatch door reads").

    So for every action, packaged ones included, the button is the only gate unless the author also declares requiredPermissions.

    Relabelled per triage 6446997136: priority:p2 → priority:p1, plus security.

    The ruling. The maintainer's standing line for security cards is 「所有的安全卡不要创建issue,直接派发处理修复」, so the endpoint half is dispatched on this card, not filed as a new one:

    1. Save door (as ordered). Every door that stores an action stores the ActionSchema parse, as fix(objectql)!: store an object's own actions as their ActionSchema parse, so a raw manifest's requiresFeature is lowered #25981 does. ⛔ No second copy of the lowering.
    2. Endpoint. The action dispatch door refuses a caller who fails the action's declared requiresFeature or requiresMembershipReach, with the existing refusal code the door uses for requiredPermissions (⛔ no new code unless none fits; then stop and report).
      • visible stays a rendering predicate, unread by dispatch. Contract point 6 is unchanged; this card enforces the two declared gate keys, not arbitrary visible expressions.
      • Read the gate from the one evaluator the lowering targets: the feature flag from /auth/config's source and the membership grade from MEMBERSHIP_REACH. ⛔ No second spelling of either rule.
      • If the parsed action no longer carries the raw keys after lowering, so that dispatch cannot see what was declared, stop and report the shape with options. ⛔ No packages/spec/** change without the seat.
      • This is Clause-②: yes (narrowing) for the endpoint, with the Skills-docs: line and the full dogfood battery.
    3. One PR or two: the implementer's call by size. If two, both carry Fixes/Part of #26723 and the save-door PR may land first.

    Four axes:

    • 实际业务需求: QA measured both gates shown to a plain member, and nothing behind the button stops the call.
    • 项目长远合理性: a declared gate that the server does not enforce is a declared-but-unenforced capability; the fix is to enforce it where declared.
    • 防 AI 写错: an AI author who declares requiresMembershipReach reasonably believes it guards the action. 声明即强制.
    • 创业阶段不扩散需求: no new key, no new gate, only enforcement of two keys that already exist.

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat ruling: #26723's endpoint half → A, no dispatch enforcement; the card returns to p2

    2026-10-27T18:55Z · domain:server seat 2 · os-project-manager · session session_0186vfjHHMsRXC9EUT9hTFMo · supersedes item 2 of 6450826243

    The implementer hit the stop condition that ruling set and measured the options. The analysis is in the project file pm-orders/server/26723-endpoint-options.md.

    • Shape: lowerRequiresFeature and lowerRequiresMembershipReach both return Omit<T, key>, so a parsed action carries only the composed visible. Dispatch cannot see the declared gate without a packages/spec/** change.
    • The spec already rules both keys as UI courtesy:
      • action.zod.ts:1553: "Client-side hiding is UX, not authorization … must also be refused server-side (requiredPermissions, or the action's own body)";
      • action.zod.ts:1572: "the endpoint's own door stays the authority";
      • membership-reach.ts:61 says the same.
    • Every in-repo producer of either key, about 45 actions in platform-objects identity, is type: 'api'. Each calls a better-auth endpoint that checks the grade or flag itself. POST /api/v1/actions/:object/:action refuses api and url actions with a 400 (headlessActionTypeError). There are zero carriers in examples/** and HotCRM.

    So no gated action that exists today is reachable through the dispatch door, and each one's own door is the authority. The defect the QA run measured, a button shown to the wrong member, is the save door's missing lowering, and the save-door PR closes it.

    Ruling: A. No dispatch enforcement, and no packages/spec/** change. B would add a second authority path for two sugar keys that nothing pulls, and C is already ruled out (contract point 6).

    • 实际业务需求: B guards zero producers.
    • 项目长远合理性: the spec already places authority at the endpoint's door and at requiredPermissions.
    • 防 AI 写错: the spec docs already say the keys are not a guard.
    • 创业阶段不扩散需求: no enforcement without a pull, and 新增门禁默认否.

    Labels: reverted to priority:p2, and security removed. The p1 · security arm of triage 6446997136 assumed a gate the button alone carried. The measurement shows each gated action's real door enforces the gate itself.

    The save-door PR says Fixes #26723.


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 26723,
    "status": "done",
    "branch": "claude/project-thread-ox9ix0",
    "pr": "#26880",
    "session": "session_01FEkQ5kBygwShyUWpFansVP",
    "premise_still_valid": true,
    "summary": "ENDPOINT: POST /api/v1/actions/:object/:action does NOT enforce requiresFeature / requiresMembershipReach or the lowered visible. It checks requiredPermissions, the activation switch and the type only, and visible is unread by contract (action-execution.ts:2484, pinned by action-declarative-update.test.ts point 6). The parsed action drops both raw keys, so dispatch cannot see them without a spec change. Seat ruled option A (comment 6450896632): no dispatch enforcement, no spec change, card back to p2. SAVE DOOR (this PR): saveMetaItem stored an object or action body as written after judging it by its schema, so the gate keys stayed raw with no visible. It now stores the parse it already ran (new withParsedStoredActions in protocol-stored-action-parse.ts, one call in protocol.ts): an object's own actions array becomes the parsed array, and a standalone action becomes the parsed action. This covers draft and active, and is the same parse the code-package door stores since #25981. Stored rows are not migrated. The showcase, the examples and HotCRM carry 0 such actions in source; a re-save fixes an old Studio row.",
    "tests": "New protocol.stored-action-gate-lowering.test.ts (7 tests: object draft and publish lowered and deep-equal to ObjectSchema.create, plain-action control, idempotent re-save, no-actions object, standalone action draft and publish). Red: protocol.ts at base blob 4ccf8acedb gives 5 failed / 2 passed (the 2 are the idempotency and no-actions controls); restored blob 92f1deab22 == HEAD, git diff HEAD empty. Ablation via ablation-replace.mjs --delete of the one call line (blob 92f1deab22 -> 2ecf252721, anchor 1 -> 0) gives 5 failed / 2 passed; restored blob == HEAD, diff empty. The test imports source, so there is no dist leg. metadata-protocol full suite at a55b8f4: Test Files 271 passed | 4 skipped, Tests 21669 passed | 26 skipped, exit 0; typecheck 0; build 0. Consumer files after building the runtime^... and rest^... closures: runtime 8 files, 544 tests passed; rest rest.test.ts, 215 passed. Endpoint pin action-declarative-update.test.ts -t point 6: 2 passed. eslint --no-inline-config on the 3 touched files: exit 0.",
    "gates": "dispatch-gates --commands at 91803f6 derived 69 families; all run; --ran reconciles 69 accounted, 68 run, 1 NOT-MEASURED: check:dual-build-cjs-loads exit 3, PREREQUISITE NOT MET (needs every dist). check:engine-double-contract was red on the first run (new double unrecorded), and the ledger was updated with --write in commit 91803f6, now exit 0. check-plugin-teardown-shape --self-test exited 3 on the shallow clone, then passed 48 cases after fetching its control commit. CI check-runs are in progress at report time; the seat reads them.",
    "dogfood_battery": "n/a: no ! in the PR title, and Clause-② is no with no (narrowing) arm",
    "mcp_calls": "11 MCP GitHub calls, all reads: issue_read x5 (get, get_comments x4), get_commit x1, search_pull_requests x1, pull_request_read get_files x1, plus 3 ToolSearch loads (not GitHub calls). No write tool.",
    "api_writes": "2 so far: relay pr_create (#26880, assignee os-project-manager) and this os-dev-report comment through post-stamped. git push is not counted.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none, noted, not filed. The spec says requiresFeature / requiresMembershipReach are UI courtesy, and an AI author may still read them as a server guard. The seat ruling A already covers this, with no new gate. Dedupe words: requiresMembershipReach authorization, action gate server enforcement, visible not authorization."
    ]
    }


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 27, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed

    2026-10-27T20:47Z · domain:server seat 2 · os-project-manager · session session_0186vfjHHMsRXC9EUT9hTFMo

    PR #26880 merged at 2026-10-27T20:46:53Z by the merge queue as 57e00f5b99 on main (Fixes #26723; ACCEPT 6452435998; Clause-②: no). GitHub closed the card as completed.

    Reading Probe Result
    Queue membership git ls-remote origin 'refs/heads/gh-readonly-queue/main/pr-26880-*' 0 refs (left the queue)
    Ancestry git merge-base --is-ancestor 57e00f5b99 origin/main holds
    origin/main by content packages/metadata-protocol/src/protocol.ts carries withParsedStoredActions
    • Landed: the metadata save door, in draft and active mode, now stores an action as its ActionSchema parse. This applies to an object's own actions and to a standalone action. A declared requiresFeature / requiresMembershipReach is lowered into visible, so the console hides a gated button the same way it hides one from a code package (fix(objectql)!: store an object's own actions as their ActionSchema parse, so a raw manifest's requiresFeature is lowered #25981).
    • Endpoint (ruling 6450896632, option A): POST /api/v1/actions/:object/:action does not enforce either key. The spec documents both as UI courtesy. Every in-repo carrier is a type: 'api' action whose own endpoint is the authority, and the dispatch door answers 400 for api and url actions.
    • Not migrated: a row saved through Studio before this fix keeps the raw keys until it is saved again.

    Generated by Claude Code

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

Metadata

Metadata

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatpm:dispatchedpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions