Skip to content

feat(spec)!: an action:group / action:menu member refuses a non-array params unless its type is api, with the action:button prescription (#21855) - #21869

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21855-member-params-object-refused
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21855-member-params-object-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21855
Clause-②: yes (narrowing)

What this does

An action:group / action:menu member's params now takes the array form (ActionParam[], the input list) only, unless the member's type is api. Any other params value on a non-api member (an object, a string, a number or null) is refused at the component-props gate, at actions.N.params, with the member prescription in the file's existing voice: static values belong on an action:button node, whose params object carries them.

This is the card's step, under the governing text it cites:

before after
a non-api member, params an array accepted accepted, byte-identical
a non-api member (an absent type included), params not an array accepted, then dropped at run time refused: custom at actions.N.params, with the prescription
a type: 'api' member, any params accepted (the request-payload window, to 18) unchanged
action:button / action:icon node, object params accepted (its static values) unchanged

The refusal message, for a navigate_edit menu member:

`params` on an `action:menu` member is the list of inputs the runner collects from the user before the action runs — an `ActionParam[]` array. The container forwards any other `params` value only for a `type: 'api'` member, as its request payload (write `bodyExtra` for that), and this member's `type` is `'navigate_edit'`, so the object written here is dropped and never reaches the action. A member's static parameter values are not part of the inline action vocabulary: to run an action with static parameter values, author it as its own `action:button` node, whose `params` object carries them.

How

  • packages/spec/src/ui/component.zod.ts.
    • A module-private refinement, actionContainerMemberParamsFitType(container), goes on both member builders (buildActionGroupMember, buildActionMenuMember) through .superRefine.
    • The member's params stays z.unknown(), so it keeps the enumeration pin's runner line. Its .describe() now states the accept set and still says "forwarded to the runner".
    • The members' docblock gains a section with the read points at the .objectui-sha pin 0abd4f9f8. The objectui renderer directory is byte-identical there, at 2e818d0b5 and at objectui main f1a177c41 (git diff --quiet).
    • strictObject's unknown-key refusal stays terminal, so a member already refused for a key is not judged a second time (pinned).
  • ⛔ What stays the same: no key added or removed, no second shape for params, no export moved, and the api window is untouched.

The ADR-0087 kit (the #21702 / PR #21712 and #21464 / PR #21764 shape)

  • The D3 semantic entry packages/spec/src/migrations/entries/semantic/18.ui-action-group-menu-member-params-array-only.ts.
  • Its STEP18_RATIONALE fragment at order 83, inserted where its id sorts. 83 is the next free order: the highest on main is 82, re-read at 5b2d189e28 and again at 2df3d13d16 after the merge.
  • registry.ts's generated region, written by gen:migration-registry.
  • One BREAKING @objectstack/spec minor changeset, .changeset/21855-action-member-params-array-only.md. It carries the Clause-② line, the adr-0087: registered ui-action-group-menu-member-params-array-only disposition marker and a FROM → TO table.
  • No tombstone, because no key is removed. No D2 conversion, because the static values belong on a different node, which no rewrite can build in the author's place.
  • No regeneration is owed for spec-changes.json and docs/protocol-upgrade-guide.md. Both project the registry from the support floor up to the current protocol major (17), so a step-18 entry is not in either yet. check:spec-changes and check:upgrade-guide are green with both files untouched, as on the two precedents.
  • packages/spec/dropped-refinements.baseline.json gains ui/ActionGroupProps and ui/ActionMenuProps (site actions.element each), exactly as build-schemas printed them. Its measured counts go 218 → 220 schemas and 676 → 678 sites. The JSON Schema projection has no arm for a custom check; the S-final stage declared its timeline refinement the same way.
  • content/docs/references/ui/component.mdx was regenerated by check:generated --fix. That run proved this the only stale artifact; the change is the two member params rows.

Census, re-run before writing (at base 5b2d189e28), with a lit control

The question: which action:group / action:menu members author a non-array params on a non-api type?

Instrument (scratchpad census.cjs):

  • A TypeScript-AST walk over .ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.json and fenced Markdown code. YAML is read as text.
  • Pass 1 lists every params key in every file that names either block, with its value kind and its owner's type. Same-file constants are resolved. A member is classified automatically when its actions owner (flat, inside a node's properties bag, or through a same-file constant array) carries the block type.
  • Pass 2 lists every non-array params on an element of any actions array corpus-wide, to catch members built in files that never name a block.
  • Every non-array hit was read by hand. Helper positions (mount(surface, entry), schema(member({ … }))) are resolved that way.
  • Lit control: a planted fixture with four non-api object members (flat, inside properties, through a const array, in a Markdown fence), one api member and one array member. The instrument found and classified all six. Separately, the hand-read loose pass reached objectui's own helper-position drop probes, below.
corpus files naming a block params keys there not an array container members with a non-array params on a non-api type
objectstack 5b2d189e28 10069 24 32 23 0. The 23 are inline element:button action conversion fixtures, schema source, the liveness ledger's params row, CHANGELOG quotations, and one properties.params refusal probe.
objectui pin 0abd4f9f8 7451 89 47 41 0 writers. The only such members are objectui's own tests asserting that the container drops the value: action-entry-object-params-10462.test.tsx:144, :193 and action-container-member-params-10290.test.tsx:196. The type: 'api' controls beside them stay accepted.
objectui main f1a177c41 7467 89 47 41 the same; zero hits differ between pin and main
hotcrm 4054ec2680 888 0 0 0 0
cloud — — — — not reachable from this session: git ls-remote asks for credentials, the API answers 403, and add_repo (read) answers "you don't have access"

Deployed metadata was not measured. So the change narrows a zero-writer spelling, and Clause-②: yes (narrowing) holds.

Tests

  • New pin packages/spec/src/ui/component-action-member-params-array-only.pin.test.ts, 39 tests:
    • §1: the refusal on both containers, each case asserting [{ code: 'custom', path: 'actions.N.params' }] exactly. The values are an object on navigate_edit, on an absent type and on url; an empty object; a string; a number; and null. One more case puts the issue on the second member. The message names the container, the member's type and the action:button prescription.
    • §2: the accept set, byte-identical. That is an array on non-api and api members, an empty array, the api member's object and string params, no params, and bodyExtra. A CONTROL shows action:button / action:icon nodes keep their object params.
    • §3: one complaint only, because the unknown-key refusal is terminal.
    • §4: the D3 entry is registered with no conversion, and the step-18 rationale names it.
  • Ablation, predicted first. Prediction: 18 red (all of §1), 21 green in the pin, and the two neighbour pins untouched.
    • Mutation, at 7a28c5194a (the component.zod.ts blob is unchanged since, through the merge): scripts/ablation-replace.mjs replaced the refinement's guard with a bare return (anchor ×1 → ×0, blob d8565fd7a8 → 930000300e), inside a driver carrying its own EXIT INT TERM restore trap on the absolute path.
    • Observed over the three pins: Tests 18 failed | 160 passed (178). The 18 were exactly the §1 cases.
    • Restore: blob d8565fd7a8 equals HEAD's, and git diff HEAD is empty.
    • The pin imports ./component.zod relatively, so it reaches src and no dist is involved.
  • The public door. lint's validateComponentProps, from src, ran over this branch's built @objectstack/spec:
    • a navigate_edit group member and a menu member with no type, each with an object params, each give one component-props-invalid finding (warning) at pages[0].regions[0].components[0].properties.actions.0.params, carrying the message above;
    • CONTROLS give 0 findings: an array on a script member, an object on an api member, and an object on an action:button node.
    • The before-state is the card's own reading: the door reported nothing for such a member.

Verification

All at head 810b1c4b18 (the merge of main 2df3d13d16, below) unless noted.

  • @objectstack/spec, vitest run --project local --maxWorkers=2 (the package's test script), under the verify lock: Test Files 616 passed (616), Tests 18426 passed | 1 todo. Before the merge, at a6f230772f, both projects (local and repo): Test Files 669 passed (669), Tests 19325 passed | 1 todo.
  • pnpm --filter @objectstack/spec typecheck, run at a6f230772f: exit 0. That covers tsc --noEmit, check:scripts-typecheck and check:test-typecheck: OK (52 files / 246 errors / 135 signatures held, unchanged). tsc -p tsconfig.test.json --listFilesOnly names the new pin, and the D3 entry is in the tsconfig.json program.
  • @objectstack/lint, whole suite, the component-props gate's package: Test Files 119 passed (119), Tests 5627 passed.
  • @objectstack/spec build, then check:generated: All 15 generated artifacts are up to date. The check:generated --fix run before the merge proved check:docs the only stale artifact, and only it was regenerated.
  • Derived gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 114 commands at 810b1c4b18, against merge base 2df3d13d1 (7 paths). Each was run with its exit code recorded before any pipe. --ran reconciled 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN, and all 114 exited 0.
    • Six first answered PREREQUISITE NOT MET (exit 3) because workspace packages were unbuilt: lint's check:doc-formula-expressions and check:doc-security-posture, spec's check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads and check:lean-entry-closure. After turbo run build --filter=!@objectstack/docs --concurrency=2 they were re-run at the same head, each reaching its own verdict with exit 0. The record holds the re-runs.
    • That build reported @objectstack/hono#build failed in its DTS-emitted check. The dist/index.d.ts it named missing was on disk right after, and a rebuild was green (31 successful, 31 total). The adapter is outside this diff.
    • Among the 114: check-adr-0087-registration --base origin/main (one declared-breaking changeset, registered ui-action-group-menu-member-params-array-only), check-changeset-no-major, check-empty-changeset, check:migration-registry, check:spec-changes, check:upgrade-guide, check:authorable-surface, check:api-surface, check:docs, check:strictness-ledger, check:doc-authoring, check:issue-citations, check:cross-package-test-inputs, check:nul-bytes, check:type-check-debt.
  • eslint, narrowed and proven. These three together make the narrowing a measurement:
    1. Population: eslint.config.mjs's **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} block covers all four changed .ts files.
    2. eslint --no-inline-config --format json: 4 files, 0 errors, 0 warnings.
    3. Invariance: the config enables no type-aware linting (no parserOptions.project, as its own comment states), so this diff cannot move a verdict on an untouched file.
  • Merge. origin/main moved 4 commits after the base (5b2d189e28 → 2df3d13d16). It was merged through scripts/pm/os-regen-merge.sh (⛔ no rebase), and none of those commits touches a file of this PR. A re-fetch just before this PR showed 4 more commits on main, none touching these files, so no second merge.
  • Not measured here: the full pnpm lint, the Console Pin Gate, the Dogfood Regression Gate and the repo vitest project after the merge. Reason: CI-owned.

Acceptance notes

  • Interpretation, stated: "array form only" is read literally. A string, a number or null params on a non-api member is refused as well as an object. The container drops each of those the same way (readActionEntryParamValues returns nothing for any non-array value on a non-api type), and the census found none of them either. If the reviewer reads the card as objects only, the change is one condition in the refinement's guard plus the string, number and null cases in §1.
  • The api window is untouched, and ending it is not this PR's job. An api member's object params stays accepted. When protocol 18 ends that window, the member refinement's type === 'api' arm is the one line that moves. Carrier: whoever ends that window. Noted, not filed.
  • objectui remainder, already carried. readMemberStaticParamValues still reads a member's properties.params, which the spec refuses. [finding] spec(ui): an action:group / action:menu member with an object params on a non-api type passes the component-props gate, and the container drops it at run time objectui#11638, as re-scoped by triage (5991243780), carries it. No objectui file is touched here.
  • Inert number in a hand-edited ledger. dropped-refinements.baseline.json's measured.refinementSitesThatDidProject reads 369, while this build prints "450 refinement site(s) DID reach the file". No gate reads that number. It is left as found; changing it is outside this card. Carrier: none. Noted, not filed.

Generated by Claude Code

claude added 6 commits October 5, 2026 09:35
… params on a non-api type

The member's params takes the ActionParam[] input list only, unless its
type is api (the request-payload window). The refusal carries the member
prescription: author an action with static parameter values as its own
action:button node. D3 entry ui-action-group-menu-member-params-array-only
with its step-18 rationale fragment (order 83); registry region regenerated.

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
…nd its D3 registration

Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 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 — 138 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 9f9510f25e6aa65aa61ce3effb42706fabcab92e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45c248503b07a3fa816895f02dbf2bd3bd810838 — the merge of head 810b1c4b1863509fc8570cefa2581346cc44a5c0 into base 9f9510f25e6aa65aa61ce3effb42706fabcab92e, 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 45c248503b07a3fa816895f02dbf2bd3bd810838 && git checkout 45c248503b07a3fa816895f02dbf2bd3bd810838
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9f9510f25e6aa65aa61ce3effb42706fabcab92e 810b1c4b1863509fc8570cefa2581346cc44a5c0 && git checkout -B drift-repro 9f9510f25e6aa65aa61ce3effb42706fabcab92e && git merge --no-ff 810b1c4b1863509fc8570cefa2581346cc44a5c0

node scripts/docs-audit/affected-docs.mjs --json 9f9510f25e6aa65aa61ce3effb42706fabcab92e

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

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 protocol:ui size/m tests tooling

Projects

None yet

2 participants