Skip to content

fix(metadata-protocol): the save door stores an action as its ActionSchema parse, so requiresFeature / requiresMembershipReach are lowered into visible - #26880

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ox9ix0
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-ox9ix0

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26723

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么: 元数据保存入口(/meta 保存对象、单独保存动作,草稿与发布两种模式)现在把动作按 ActionSchema 解析后的结果存盘,requiresFeature / requiresMembershipReach 会被并入 visible。
  • 为什么改: 以前这两个门槛原样存着、没有 visible,控制台只读 visible,所以普通成员也能看到只给管理员或特定功能开放的按钮。
  • 风险与回滚: 只影响新保存的动作;已存的旧行不改写,重新保存一次即修好。回滚就是 revert 本 PR。
  • 先看: packages/metadata-protocol/src/protocol-stored-action-parse.ts(新 helper);protocol.ts 保存入口里新增的那一行。
  • 两分钟验证: pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.stored-action-gate-lowering.test.ts,应见 7 passed。
  • 需要你做的: 无,席位落地。

What changes

saveMetaItem (packages/metadata-protocol) judges an object or action body by its spec schema. It then stored the request body as written, apart from the operator and form-section grafts. ActionSchema's parse is what lowers requiresMembershipReach and requiresFeature into visible, so a stored action kept both gate keys raw and had no visible. The console reads visible alone, so the button was offered to every user. This was measured on origin/main 4c29f38447 by the red leg below.

The door now stores the parse it already ran. withParsedStoredActions (new, protocol-stored-action-parse.ts) is called once, right after the existing grafts:

  • object: the object's own actions array becomes the parsed array (ObjectSchema's actions is ActionSchema[]). Every other key of the body stays as written.
  • action: the body is the parsed action.
  • Any other type, or an object with no actions array, is unchanged.

This is the same parse the code-package registration door has stored since #25981 (parsePackageAction in @objectstack/objectql). The lowering is not re-implemented here: the values are read off the parse tree the door already computed. The parse is idempotent over its own output, so a GET then PUT round trip stores the same bytes (pinned).

The schema block runs before the draft/active split, so both modes are covered by one hunk.

The action endpoint (measured, ruled)

Asked by the triage line: does POST /api/v1/actions/:object/:action enforce the gate? No.

  • The door checks requiredPermissions (actionPermissionError, packages/runtime/src/action-execution.ts:832), the activation switch and the action type.
  • visible is deliberately unread there (contract point 6, action-execution.ts:2484). It is pinned by action-declarative-update.test.ts "point 6", run locally: 2 passed.
  • The parsed action no longer carries the raw keys: both lowerings return the input without the key. So dispatch cannot see what was declared without a spec change.
  • The spec already rules both keys UI courtesy, not authorization (ui/action.zod.ts docblocks on visible and requiresMembershipReach).
  • Every in-repo carrier is a type: 'api' platform-objects identity action calling a better-auth endpoint, whose own door is the authority. POST /actions answers 400 for api and url actions.

The seat ruled option A on the card: no dispatch enforcement and no spec change. This PR fixes the save door only.

Tests

New: packages/metadata-protocol/src/protocol.stored-action-gate-lowering.test.ts, through the real saveMetaItem, reading the sys_metadata row the save wrote:

  • An object saved in draft and in publish mode, with one action carrying requiresFeature: 'ssoEnforced' and one carrying requiresMembershipReach: 'delete_organization', is stored with neither key. Each action has its lowered visible, deep-equal to what ObjectSchema.create gives the same declaration.
  • Control: an action without the keys is stored as its parse with no visible, and the object's other keys are stored as written.
  • Re-saving a stored body stores the same actions (idempotent).
  • An object with no actions key is stored without one.
  • A standalone action saved in draft and in publish mode is stored lowered, deep-equal to ActionSchema.parse.
Red first, ablation
  • Red. With protocol.ts written back to the base blob 4ccf8acedb (no withParsedStoredActions reference): Tests 5 failed | 2 passed (7). The two greens are the idempotency and no-actions controls. Restored: blob 92f1deab22 equals HEAD, and git diff HEAD is empty.
  • Ablation (scripts/ablation-replace.mjs --delete, anchor request.item = withParsedStoredActions(singularType, request.item, parsed.data);, hit once): blob 92f1deab22 to 2ecf252721, anchor 1 to 0. Result: Tests 5 failed | 2 passed (7). The tool restored it: blob == HEAD (92f1deab22f0) and git diff HEAD is empty. The test imports ./protocol.js from source, so there is no dist leg.

Acceptance notes

  • Stored rows are not migrated (ruling). The showcase, the other examples and HotCRM declare neither key on any action (0 carriers in source), so none of their rows carry it from code. A row an author saved through Studio before this fix keeps the raw keys until it is saved again; saving it again stores it lowered.
  • What else the parse adds to a stored action: schema defaults, and a CEL string shorthand wrapped in { dialect, source }. That is the shape a packaged action is already served in.
  • The UI needs no change. Nothing under ui/** reads requiresFeature or requiresMembershipReach at runtime. ui/packages/core/src/actions/ActionRunner.ts:469 is a type declaration only, and the renderers read visible.
  • Studio round trip. After a save, the action shows the composed visible and no gate key, as a packaged action already does.
  • Overlap. fix(metadata-protocol): a draft's _diagnostics reports what its publish will refuse #26856 is the only open PR touching protocol.ts, and its hunks are far from this one: they sit at lines 216 to 11001, and this hunk is at about line 20950.

Verification

Head 91803f6f.

Package suites and typecheck
  • pnpm --filter @objectstack/metadata-protocol test: Test Files 271 passed | 4 skipped (275), Tests 21669 passed | 26 skipped (21695), exit 0. typecheck exit 0, build exit 0. Run at a55b8f4b; the only later commit is the engine-double ledger JSON.
  • Consumer files that save actions through the door, after building the @objectstack/runtime^... and @objectstack/rest^... closures with this fix in metadata-protocol/dist:
    • runtime: http-dispatcher, action-activation-unpackaged, field-error-code-catalog, mcp-package-object-discovery, actions-disabled-package, meta-overlay-read-your-writes, stored-metadata-body-boundary.pin, stored-metadata-reader-contexts.pin. Result: Test Files 8 passed (8), Tests 544 passed (544).
    • rest: rest.test.ts. Result: Tests 215 passed (215).
  • eslint --no-inline-config over the three touched metadata-protocol files: exit 0.
  • test:repo and the full runtime and rest suites are left to CI (Test Core).
Gates
  • dispatch-gates.mjs --commands on this diff derives 69 families, and all 69 were run at 91803f6f. --ran reconciles them as 68 run and 1 NOT-MEASURED.
  • The NOT-MEASURED family is check:dual-build-cjs-loads, which exited 3 (PREREQUISITE NOT MET: it needs every package's dist/, and this worktree has only the closures built). CI runs it.
  • check:engine-double-contract first named the new test's double. The ledger was updated with --write in the second commit, and the family is green.
  • check-plugin-teardown-shape --self-test exited 3 on the shallow clone. After fetching its control commit it passed (48 cases).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5kBygwShyUWpFansVP


Generated by Claude Code

…chema parse, so requiresFeature / requiresMembershipReach are lowered into visible

An object's own actions and a standalone action item were judged by
ActionSchema and then stored as written: the gate keys rode along raw and
no visible was stored, so the console offered the button to everyone. The
door now stores the parse it already ran (withParsedStoredActions), the
shape the code-package registration door stores since #25981.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5kBygwShyUWpFansVP
…e in the pinned ledger

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5kBygwShyUWpFansVP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 2 documentable anchor(s).

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

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

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1a8b7ebef23aefacc78f5ed0f1850ba9fe6f59dc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 193949654708c630ac0f2ab0aa30c5bd6190302a — the merge of head 91803f6ff4b06680284c5fa8b4bc88c5e27fb507 into base 1a8b7ebef23aefacc78f5ed0f1850ba9fe6f59dc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 193949654708c630ac0f2ab0aa30c5bd6190302a && git checkout 193949654708c630ac0f2ab0aa30c5bd6190302a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1a8b7ebef23aefacc78f5ed0f1850ba9fe6f59dc 91803f6ff4b06680284c5fa8b4bc88c5e27fb507 && git checkout -B drift-repro 1a8b7ebef23aefacc78f5ed0f1850ba9fe6f59dc && git merge --no-ff 91803f6ff4b06680284c5fa8b4bc88c5e27fb507

node scripts/docs-audit/affected-docs.mjs --json 1a8b7ebef23aefacc78f5ed0f1850ba9fe6f59dc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1a8b7ebef23aefacc78f5ed0f1850ba9fe6f59dc → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-27T20:10Z · head 91803f6f · #26723

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main, assignee os-project-manager.
    • These body lines are all line-initial:
      • Fixes #26723
      • Clause-②: no
      • Metadata-protocol: none
    • One @objectstack/metadata-protocol patch changeset, carrying both declaration lines.
    • check-governed-merges --pr 26880 reads NOT governed, 225 changed lines.
    • The card's newest claim (6450751670) names this PR's branch.
  2. Diff against the rulings (the seat read the whole non-test diff):
    • The seat's ruling A (6450896632) is applied: there is no dispatch-door enforcement and no packages/spec/** change. The report's first fact restates the endpoint measurement.
    • The save door: one helper, withParsedStoredActions (protocol-stored-action-parse.ts, 35 lines), with one call in saveMetaItem after the existing grafts.
      • An object keeps every authored key except actions, which becomes the parse's array.
      • An action becomes its parse.
      • Every other type is unchanged.
    • It does not lower anything itself: the values come from the ActionSchema parse that the door already ran, the same parse the package door has stored since fix(objectql)!: store an object's own actions as their ActionSchema parse, so a raw manifest's requiresFeature is lowered #25981.
    • ActionSchema is a strictObject, so storing the parse drops nothing an author could write. Aliases fold to their canonical key, the shape a packaged action is already served in.
    • Clause-②: no is accepted. No accepted body is refused, and readers already read the packaged shape.
  3. Tests:
    • The new protocol.stored-action-gate-lowering.test.ts has 7 cases through the real saveMetaItem, reading the stored row. They cover object draft and publish (deep-equal to ObjectSchema.create), a plain-action control, an idempotent re-save, an object without actions, and a standalone action in draft and publish.
    • Red on the base blob: 5 failed | 2 passed. The 2 that pass are the idempotency and no-actions controls. The ablation of the single call line gives the same 5 | 2, then is restored.
    • metadata-protocol: 21,669 passed. Runtime and rest consumer files: 544 + 215 passed.
  4. Overlap:
  5. Gates:
    • At this stamp every check-run on the head reads success or skipped: 34 success, 11 skipped, none failed or in progress.
    • Locally there were 69 derived families: 68 run, and 1 NOT-MEASURED (check:dual-build-cjs-loads, which needs a full build). CI covered that one.
  6. Out of scope:
    • Stored rows saved before this fix are not migrated. Re-saving a row stores it lowered.
    • The report's single finding is noted and not filed: it is already covered by ruling A.

Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants