Skip to content

feat(spec)!: object-metric drillDown.report is ReportSchema, object-timeline items the entry kind its variant selects, and action:group / action:menu members a closed inline action (#21464, S-final) - #21764

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21464-s-final
Oct 4, 2026

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21464
Clause-②: yes (narrowing)

What this does

The S-final stage of the ComponentPropsMap z.unknown() close-out, and its last. It executes the maintainer's rulings on forks 1, 4 and 5 of the decision card #21704: fork 1 letter B (ruling record 5978663135, batch #276), forks 4 and 5 letters B and A (record 5979239990, batch #277), per the claim 5981629450. Read points are at the .objectui-sha pin 2e818d0b51ec, under objectui packages/ unless named. Every cited reader file is byte-identical at objectui main 2abec3a96 (the pin is an ancestor of it; plugin-timeline/src/renderHandoff.ts and types/src/data-display.ts differ there in comments and a typed click slot only, not in either arm).

row · member was now
object-metric · drillDown.report z.unknown() ReportSchema, by reference
object-timeline · items z.array(z.unknown()) a closed entry (module-private, built once) of objectui#6356's two arms, paired with the row's variant by a row refinement
action:group · actions[] z.record(z.string(), z.unknown()) per member a closed inline action (module-private): action:button's keys by type, plus the inline button's size
action:menu · actions[] the same the same member without size

With these three typed, the enumeration pin's fork lines are gone, the fork stage is gone with them, and a new §6 pins the close-out: no stage is declared and no ledger line is staged.

Fork 1 B — drillDown.report is ReportSchema

  • The read. The tile hands report to the shared drawer verbatim (plugin-dashboard/src/ObjectMetricWidget.tsx:742). DrillDownDrawer.tsx draws it as a report node when isDatasetBoundReport holds (:92, used at :115) — a non-empty dataset, or a joined report with a block that binds one — joining the metric's filter into the report's own runtimeFilter (:150-153). Any other value lists the records. objectui types the member as this package's ReportSchema author input (SpecReportInput). Both files are byte-identical from ab1879721595, where the fork was measured.
  • The premise, re-measured: admitted implies drawn. Since [finding] spec(report): a type: 'joined' report whose blocks bind no dataset parses and passes objectstack validate, while ReportSchema's own refinement comment and reports.mdx say "each block dataset-bound" #21702 (ed15448217) the joined arm refuses every block with no dataset, and every other type needs dataset and values. The new pin's §4 restates isDatasetBoundReport from the pin and checks it over 14 candidate reports: every report the member admits is drawn (zero exceptions), and the four writer shapes are admitted (a lit control).
  • The remaining difference runs one way only, and is pinned as such. The drawer also draws four incomplete reports the member refuses: a joined report with only some blocks bound, a joined report with a container dataset, a report with no name / label, and a summary report with no values. No measured writer authors any of them.
  • drillDown's other members, stage 5's. enabled, title, target, columns and maxRows stay the chart drill-down's by reference, and filter / mode stay refused by name. The block's describe and docblocks now say report is ReportSchema. The metric family pin's §3 key set is unchanged.
  • Parsed type. ReportSchema's defaults (type, drilldown) materialize on parse, so ObjectMetricPropsParsed now also differs from the authored type on drillDown.report. Its docblock says so. A page component's properties is not parsed on the way to the renderer, so the drawer still reads the report as written.

Fork 4 B — the timeline entry, both arms closed

  • The arms, read at the pin. The feed branches read time, title, description, variant, icon, content and className (plugin-timeline/src/renderer.tsx:1612-1659 vertical, :1697-1716 horizontal). The gantt branch reads a row's label (:1899-1900) and items (classifyGanttRows, :617-621; drawn at :1908), and each bar's startDate, endDate (:1909), variant (:1916) and title (:1918, :1921). That is exactly objectui's TimelineFeedItem (seven keys) and TimelineGanttItem / TimelineGanttItemBar, taken as ruled.
  • One entry shape, not a union. The entry declares every member of both arms, each optional, closed against anything else — objectui's own TimelineItemSchema shape. A union would fold an off-shape bar's issue into one invalid_union at the entry, as objectui's docblock records.
  • The row refinement (objectTimelineItemsFitVariant, restating objectui's timelineItemsFitVariant) pairs each entry with the arm the row's variant selects (absent means vertical). It refuses, each at the key it names: the arm's required key absent (title on a feed entry, label on a gantt row), or a key only the other arm declares. The arm key lists are read off the two shapes, never restated.
  • content is opaque, by the ruling: z.unknown(), its describe says "Held opaque", and the enumeration pin records it under a new opaque reason naming the ruling record (§2 checks both). It is not a slot position.
  • Gantt-bar dates are a string or a finite number (z.number() refuses Infinity and NaN). The Date arm is left out, by the ruling.
  • The record-composed keys (color, startDate, endDate, group, meta on an entry) are refused, each with what an authored entry writes instead. date is an alias for time.

Fork 5 A — the container members, measured from the reads

  • The read set, measured from action-group.tsx (InlineActionButton :91-174, DropdownActionItem :188-247, handleExecute :306-385), action-menu.tsx (ActionMenuItem :92-150, ActionAutoTrigger :177-191, handleExecute :244-334), static-params.ts (:142-148, :172-183) and auto-trigger.ts (:96). It is not transcribed from UIActionSchema. All four files are byte-identical from ab1879721595 and at objectui main.
    • Drawn: label (or name), icon, variant and tags (separator-before, the one tag read, :224 / :408).
    • Gated: visible and disabled.
    • Placed: locations (actionRendersAt on the group, :304).
    • Forwarded to the runner: type, name, label, description, target, openIn, method, params, bodyExtra, bodyShape, operation, patch, confirmText, successMessage, errorMessage, refreshAfter, locations, toast, resultDialog, onSuccess and objectName.
  • That is action:button's keys by type, with two differences the reads decide, and §3 derives the key set from ActionButtonPropsSchema.shape to pin them. undoable and recordIdField are not forwarded by either container. tags is drawn by both. size is read only by a group's inline button (:124, md drawn as default); an action:menu item reads none and declares none (the action:icon precedent).
  • Value schemas are the rows', key by key. visible / disabled take the rows' own actionCondition(), and §3 checks the same accept set and the same envelope. The runner-forwarded blocks stay z.unknown() with "forwarded to the runner" in their describes, so the enumeration pin's runner reason holds for each.
  • Refused, each with a prescription:
    • actionType → type (the rows' alias table, turned round);
    • endpoint / url / path / href → target (the rows' ACTION_TARGET_ALIASES);
    • enabled (the rows' own text) and autoTrigger (the rows' text on action:menu; on action:group, which never reads it, the text says so);
    • outcomeMessages → successMessage;
    • a member className → variant, or the node's own className;
    • properties → bodyExtra, or the action as its own action:button;
    • undoable / recordIdField;
    • an action:menu member's size.
  • outcomeMessages stays undeclared on all four action blocks. §3 pins that none of action:button, action:icon, action:group, action:menu and neither member shape declares it. The action:button / action:icon rows are not edited.

The census (writers of all three members)

Instrument. This run's TypeScript-AST walk over code and fenced docs, with same-file constants and spreads, .map over a constant list, templates and same-file helper calls evaluated. It reads:

  • object literals naming the block (the type also through a spread constant);
  • typed literals;
  • direct parses through the row (receiver constants resolved);
  • the block's JSX component (schema={…}, or ObjectMetricWidget's own props);
  • helper parameters at every same-file call site, for both member and whole-node positions;
  • a loose pass over every report / items / actions key in a file naming a block.

Every static value was parsed through this branch's rows; each value with a non-static part, and each refusal, was read by hand. Cross-check: it reproduces stage 5's drillDown population exactly (26 values) and the S-objectui-held census's 40 action:group / 19 action:menu values.

  • objectstack at 1289925c0a (the base; main moved 5 commits to 33f97917ac, merged here through os-regen-merge.sh, none touching a census corpus file):
    • one drill report (the metric pin's own), which parses;
    • one timeline items (component-element-navigation-17987.test.ts:213, a feed entry), which parses;
    • three container members (component-action-element-rows-20371.test.ts), which parse, plus that file's two probes the old row already refused;
    • no example, doc or skill writes any of the three.
  • objectui at the pin 2e818d0b51ec and at main 2abec3a96, the same counts at both:
    • drillDown.report: of the 26 drillDown values, 2 carry a report. The drawn one (objectMetricDrillDownMembers-8071.test.tsx:281) parses. The other is that file's it.each pair (:305), the two values the drawer does NOT draw, both refused. The loose pass's 34 report keys: 17 parse (drill-mirror tests and the dashboard guide); 9 are refused, all refusal probes on objectui's own faces ({ name }, { name: 42 }, the matrix with no values in drill-down-report-name-retired-11517.test.ts); 4 are not static and 4 are not report objects (i18n strings).

    • items: 18 values. 15 parse: feed entries, gantt rows and the empty gantt. The 3 refused are the render-time gantt date diagnostic's own probes, an array, false and null bar date (timeline-gantt-date-spelling-6907.test.tsx:338, timeline-gantt-date-type-rule-6781.test.tsx:419, timeline-gantt-null-date-6770.test.tsx:220).

    • Members: action:group 40 values (26 parse, 2 refused, 12 partly non-static) and action:menu 19 (11 parse, 3 refused, 5 partly non-static), all in tests but one run-time hand-off. Every refused member is a probe of a read the ruling refuses:

      • the member pin's className (action-group-menu-inputs-11168.test.tsx:249);
      • the outcomeMessages forward tests (action-outcomeMessages-forward-11344.test.tsx:147, :158);
      • the properties.params static-value tests (action-container-member-params-10290.test.tsx, read by hand);
      • the host's autoTrigger flag (action-overflow-autotrigger.test.tsx:298, and as a test device in action-onSuccess-forward.test.tsx:182, action-objectName-onClick-4202.test.tsx:127 and action-menu-host-disabled-11182.test.tsx).

      The partly non-static members are predicate variables, helper-built members and code-composed onClick functions, read by hand; their static keys parse.

    • The run-time hand-off is action:bar's overflow menu (action-bar.tsx:287). It hands the bar's own members — the registered actions a host passes — to action:menu at run time, never through the component-props gate, and those members are ActionSchema entries (the ruled-out option C). Recorded, not a block writer.

  • hotcrm at 4054ec2680 and cloud at 2205b53010: no writer of any of the three. hotcrm's four object-metric tiles declare no drillDown.
  • No measured working writer is refused, so the stop valve does not trip on any of the three members.

Release

  • .changeset/21464-component-props-report-items-action-members-typed.md: '@objectstack/spec': minor, the BREAKING banner, Clause-②: yes (narrowing), the ADR-0087 registered marker naming the three ids, a FROM → TO table and the census.
  • Three D3 entries in step 18: 18.ui-object-metric-drill-down-report-typed.ts, 18.ui-object-timeline-items-typed.ts and 18.ui-action-group-menu-members-typed.ts. The semantic region was regenerated by gen:migration-registry.
  • Three rationale fragments at orders 80, 81 and 82, the next free after S-forms' 79.
  • No D2 conversion and no RETIRED_KEYS_BY_MAJOR row: page-component properties is not on the save or load path, and no declared key is removed.
  • No export is added or removed: the entry, bar and member builders are module-private.
  • Generated: content/docs/references/ui/component.mdx (two member tables and the timeline entry table) and the strictness counts (ui/ 204 → 208, component.zod.ts 74 → 78: the entry, the bar and the two members).
  • dropped-refinements.baseline.json 670 → 676. ObjectMetricProps gains the five ReportSchema refinement sites carried by reference, the S-forms growth-by-reuse precedent. ObjectTimelineProps gains its root, the new pairing refinement.
  • Dogfood expression-conformance ledger: two rows, cel-action-member-visible (fail-closed) and cel-action-member-disabled (fail-closed), for the two new positions actionContainerMemberShape.visible / .disabled. Each has one evaluation leg, the container's, and no node gate (feat(spec): ComponentPropsMap rows for action:button/group/menu/icon and element:definition-list/repeater #20420 and the S-forms rows are the precedent).

Tests

  • Red first, through the published @objectstack/spec@17.6.0 (npm tarball, ComponentPropsMap[type].safeParse): 33 of the 34 values this branch refuses are ACCEPTED there. The one refused, items: 42, was already refused by the old z.array (a control). On this branch all 34 are refused.
  • The new pin component-report-items-action-members-typed.pin.test.ts:
    • §1: the writer shapes parse — timeline entries and members byte-identical, drill reports to exactly ReportSchema.parse(report);
    • §2: 34 refusals by code and path (the red-first set), plus the prescriptions;
    • §3: the vocabulary pins (the report def identity, the arms' key sets, the member key set derived from the button's, outcomeMessages absent on all six faces, the shared condition);
    • §4: admitted implies drawn;
    • §5: the D3 ids.
  • The enumeration pin: 23 new reasoned lines (the member predicates' ast, the runner-forwarded members, the report's two runtimeFilter positions, the opaque content), the four fork lines gone, and §2 and §6 added. The metric family pin: the drawn report row moves out of the byte-identical table into its own case, asserting the parse equals ReportSchema's answer (no expect removed, no skip).
  • pnpm --filter @objectstack/spec test at b7335d8374: Test Files 615 passed (615); Tests 18358 passed, 1 todo. pnpm --filter @objectstack/spec typecheck at the same head: exit 0, test layer held (52 files / 246 errors / 135 signatures; the touched pins are on tsconfig.test.json). pnpm --filter @objectstack/lint test: 119 files, 5627 tests passed. The showcase validate: passed. Dogfood test/expression-conformance.test.ts: 7 passed.
  • Ablation, one leg per member (two for the timeline), each through scripts/ablation-replace.mjs in a driver with an EXIT / INT / TERM trap, restored and proven by blob hash (HEAD blob b4dcaf34d2, git diff HEAD empty after every leg):
    • report → z.unknown(): 17 red;
    • items element → z.unknown(): 18 red;
    • the pairing refinement dropped: 5 red;
    • the action:group member → an open record: 15 red;
    • the action:menu member → an open record: 13 red.
  • Public door, through built lint's validateComponentProps. Nothing is reported for the drawn report drill, feed entries or group members. component-props-invalid is reported at drillDown.report.dataset / drillDown.report and at items.0.title / .label / .items. component-props-unknown-key is reported for an entry's color, a member's actionType and a menu member's outcomeMessages.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands gives 114 commands (merge base 33f97917a, 14 paths, 1705 changed lines). All 114 exit 0 at b7335d8374, and the --ran reconciliation reads 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. Two first ran as PREREQUISITE NOT MET (check:skill-examples, check:dual-build-cjs-loads) and were re-run green after turbo run build --filter=!@objectstack/docs (72 successful).
  • The changeset gates with this body as the pull_request payload (--event): check-changeset-no-major (LEVEL AXIS: yes (narrowing), no moved package graded patch), check-adr-0087-registration (one declared-breaking changeset, registered with the three ids) and check-empty-changeset, each exit 0.
  • eslint, a proven narrowing of pnpm lint: eslint --no-inline-config --format json over the 10 changed .ts files reads 10 files, 0 errors, 0 warnings. The population is the repo's one eslint.config.mjs, which ignored none of the 10. The narrowing excludes nothing: that config never enables type-aware linting (eslint.config.mjs:327-328, no parserOptions.project), so this diff cannot move a verdict on an untouched file.
  • NOT MEASURED: the Console Pin Gate, the Dogfood Regression Gate's full suite and the full pnpm lint, reason: CI-owned. objectui was read at the pin and at main, not built against this spec.

Acceptance notes

Noted, not filed:

  • A member's object params is forwarded as the request payload of a type: 'api' member only. On any other type the container drops it with a development warning (static-params.ts:172-183). The member keeps params as the rows do (z.unknown(), runner-forwarded), and its describe says so.
  • The action:bar hand-off above composes action:menu members from the host's registered actions at run time. Those carry ActionSchema keys (outcomeMessages, order, component, …) the member shape refuses. No gate judges that composition, and the action:button row's docblock already records the same member path for outcomeMessages.
  • The D2 conversion action-block-endpoint-to-target rewrites a stored endpoint on action:button / action:icon nodes only. A stored member endpoint is not rewritten. The census found no writer of one, and the refusal carries the rename.
  • Earlier step-18 rationale fragments (ui-object-metric-drill-down-typed, ui-object-timeline-mapping-typed) still say these members "stay open". The new fragments (orders 80-82) follow them and say they are now typed, so the joined text reads in order. The older fragments are not edited, the S-forms precedent.
  • The grid widget's camelCase keys (objectui#11610, landed on objectui main as 2abec3a9, not yet at the .objectui-sha pin) are not declared on the S-forms runtime form field here. That is fork 2's follow-up, outside this claim's file surface; its carrier is the next pin bump.

Not in this PR

  • No action:button / action:icon row edit, and no view.zod.ts / report.zod.ts edit.
  • No objectui change and no pin bump.

Generated by Claude Code

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:ui tests tooling labels Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 31 documentable anchor(s). ⚠️ 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.

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

  • content/docs/ai/skills-reference.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/automation/flows.mdx (via actionType (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES), visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/data-modeling/field-types.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/data-modeling/fields.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/data-modeling/formulas.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/data-modeling/schema-design.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/data-modeling/validation-rules.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/deployment/environment-variables.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/deployment/validating-metadata.mdx (via actionType (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/protocol/objectui/concept.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/protocol/objectui/layout-dsl.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/ui/pages.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/ui/views.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))

⛔ 9 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v12.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v15.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v16.mdx (via actionType (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES), visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v17/17-0.mdx (via actionType (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES), visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v17/17-2.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v17/17-4.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v17/17-5.mdx (via visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))
  • content/docs/releases/v17/17-6.mdx (via actionType (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES), visibleWhen (symbol, a field of const object ACTION_CONTAINER_MEMBER_ALIASES))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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
  • 7 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 a6a7547074d2f705f7a2c3f525250099c42b6b72 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a6a7547074d2f705f7a2c3f525250099c42b6b72

⚠️ 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 a6a7547074d2f705f7a2c3f525250099c42b6b72 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 17:22
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 17:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit 4331a6b Oct 4, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21464-s-final branch October 4, 2026 18:04
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…te the canonical fallback slot literal (ADR-0090 D3) (objectstack-ai#21770)

Fixes objectstack-ai#21387
Clause-②: no (narrowing)

Executes ruling A2 (record 5979854058, director batch objectstack-ai#278, maintainer
「同意」) as one PR. Draft. The ADR-0087 disposition step runs in the `Check
Changeset` job, which is NOT one of the seven required contexts. It read
red at `022bb0fa` because the honest disposition needed a ledger entry
outside the card's first surface. Patch round 1 (below, surface revision
2 in seat verdict 5982629646) adds that entry, and the step is now
green: `Check Changeset` read success on `3f4333ef`, and
`check-adr-0087-registration --base origin/main` exits 0 at `24f5c5a9`.
The patch reaches `packages/spec/src/**`, so an at-tier contract review
PASS is owed before landing. The review of `24f5c5a9` failed (record
5983490043) on the entry's step and the changeset's package list; patch
round 2 (below) fixes both, and a fresh review is owed on `d0a53cc6`.

## What changes

- **The equivalence.** `role:` comes out of `POSITION_ADDRESS_PREFIXES`
(`packages/plugins/plugin-approvals/src/approver-address.ts`).
`position:POSITION` is now the one spelling of a position address. Every
reader already takes its addresses from this one module: `resolveActor`,
the "My Pending" filter (`approverRequestIds`), the participant gate
(`visibleRequestIds`), `can_act` (`attachViewers`), and every decision's
slot test (`takenSlot` / `heldSlot`). So the prefix is the only line of
logic that changes on the reader side. No `named === role:...` compare
existed on `main`: `resolveActor` already read `positionAddresses`.
- **The writer.** `resolveApproverSpec`'s empty-lookup fallback returned
`${a.type}:${a.value}`, which is the AUTHORED type. It now returns
`${type}:${a.value}`, where `type` is
`canonicalApproverType(String(a.type))`. A flow that still authors the
deprecated `{ type: 'role', value: X }` and whose membership-tier lookup
finds no one now opens an `org_membership_level:X` slot, never `role:X`.
`DEPRECATED_APPROVER_TYPES` holds only `role`, so no other type's
literal moves. No stored slot is rewritten, there is no alias window,
and the spec's `ApproverType` `role` alias is untouched.
- **Comments.** Every comment in `approver-address.ts` and
`approval-service.ts` that called `role:POSITION` a live spelling is
rewritten. The two exemplar comments (remind fan-out, display-name
lookup) now use `position:` as their example of a `type:value` literal.
- **Bounded in-place fix, same defect class.**
`sys-approval-approver.object.ts` named `role:` as the example slot
literal in its docblock and in the `approver` field's `description`.
Both now say `position:`. All four conditions hold: it is the same class
(role: called a live slot spelling); the fix is mechanical; the dispatch
reported no claim on `plugin-approvals`; and it is in the same package
and gate family. `git grep` finds the description string in no generated
artifact or translation bundle. The claim's file surface did not list
this file.
- **Docs.** `content/docs/automation/approvals.mdx` had four sentences
that said a position matches, acts or takes a slot "under either
spelling". They now name `position:` as the one address. The
deprecated-type callout states which slot the fallback leaves and gives
the one-line fix. The admin-override callout names both request classes.
The retired prefix is named in words, because `check:role-word` holds
that page at its one existing occurrence.
- **Changeset.** `.changeset/21387-retire-role-arm.md` is `minor`, with
the `Clause-②: no (narrowing)` line, the FROM `role:POSITION` → TO
`position:POSITION` line, the author's one-line fix `{ type: 'position',
value: ... }`, and the admin's one-line handling for both request
classes.

## Pins

In `plugin-approvals`. Every turned pin keeps its `expect`s, flipped to
the retired answer.

- **The card's three pins.** These are in the new block "the role: arm
retired; the override decides what it leaves (ADR-0090 D3)" in
`approval-service.test.ts`, plus the enumeration in
`approver-address-readers.test.ts`.
1. A `position:POSITION` slot is listed, counted, `can_act: true`, and
decided by its holder with the default actor. The decision records
`actor_id` as the holder, `acted_as` as the slot, and `via_override:
false`.
2. `role:POSITION` is no longer an address of that slot. It is not
listed and not counted for the holder, not matched for SYS, and a named
actor `role:POSITION` is refused (`FORBIDDEN: cannot act as`). The slot
and the action log are untouched.
3. **Enumeration.** No `plugin-approvals` writer produces a `role:`
slot, and no reader compares one. There are two scans over every
non-test `.ts` under `src/`, `approver-address.ts` included:
- the string literals, read from the syntax tree, so comments are masked
by construction. No literal may carry a `role:` prefix. The positive
control is the scan finding `position:` in `approver-address.ts`.
- every template of the shape `${X}:${Y}`, classified in a ledger. None
may interpolate an `X.type` property (the authored type), and
`resolveApproverSpec`'s writer is pinned by name to
`canonicalApproverType(...)`.

A planted-source case shows both scans catch what they name, and that a
comment does not trip them.
- **The admin rescue, permanent.** This is the measurement from
`5979222082`. Two request classes run, each against three admin doors:
- The two classes: a stored 15.x-era `role:POSITION` slot, planted in
the CSV and then indexed by the plugin's own `rebuildApproverIndex`; and
a new `{ type: 'role', value: POSITION }` request whose tier lookup
finds no one.
- The three doors: `admin_full_access`, `PLATFORM_ADMIN` posture, and a
same-org `TENANT_ADMIN` posture.

Each admin decides through `decide()` with `actorId` set to the caller,
which is what `POST .../approve` passes. The result is `finalized`,
`approved` and `resumed: true`, with the resume signal `branchLabel:
approve`, and the action records `actor_id` as the admin, `acted_as:
null` and `via_override: true`. A reassign to the holder, after which
the holder decides with the default actor, also rescues both classes.
The holder of the same-named position is refused on both classes: the
request is not listed, not visible, `can_act` is false, and the default
actor and `role:POSITION` are both refused. **Ruling A's stop condition
is not triggered:** the rescue decides both classes on the post-change
code.
- **Turned pins.** `approver-address.test.ts`: the prefix list, the
fold, the colon split, the acting addresses, the named actor and
`heldSlot`, with `role:` added to the "equivalent only to itself" table.
`approval-service.test.ts`: the objectstack-ai#21350 block (list under `role:` is
empty; a stored `role:` slot is invisible to the holder; the SYS filter
miss), the `resolveActor` oracle (the pre-extraction predicate minus its
`role:` arm; admitted count pinned at exactly 5), and the objectstack-ai#21379
decision block (the holder naming `role:` is refused, and a stored
`role:` slot is refused to the default actor). The fallback pin "keeps
its legacy literal" is now "falls back to the canonical literal, in the
slate and in the index": `['org_membership_level:admin']`, plus the
index row.
- **Dogfood (cross-lane `domain:cli`, declared on objectstack-ai#6024 `5981995488`).**
In `my-pending-position-address`, the reviewer and submitter rows under
`role:` now assert no rows, `role:` joins the admin's "must not fold"
list, and the reviewer approving as `role:POSITION` asserts `[403,
'FORBIDDEN']`. In `position-address-readers`, the holder's `can_act` row
and the bystander row ask under `position:`, the holder under `role:`
lists nothing, and approving as `role:POSITION` asserts `[403,
'FORBIDDEN']` with nothing recorded. The holder then decides the same
request under `position:`. Every `position:` row is unchanged.

## Ablation (one-shot, not kept)

Both legs use `scripts/ablation-replace.mjs` in wrap mode on committed
HEAD `bce0f4a2`, under the verify lock. Each leg proves the anchor count
1 → 0, the replacement count 0 → 1 and the blob change, and each restore
proves the blob equals HEAD and `git diff HEAD` is empty. Both suites
resolve `plugin-approvals` SOURCE, through relative imports in the
package and the dogfood vitest alias to `src/index.ts`, so there is no
dist leg.

1. **The `role:` arm put back** (`['position:']` → `['position:',
'role:']`, blob `c5691d3d` → `8afd6cd0`): the unit suites went red, 3
files, 16 failed / 347 passed. The dogfood went red, 2 of 2 failed. For
example, the readers scan reported `approver-address.ts:64 · "role:"`,
and the dogfood reported `reviewer under 'role:my_pending_reviewer':
expected [ {...} ] to deeply equal []`. Restored to `c5691d3d`.
2. **The authored fallback put back** (`${type}` → `${a.type}`, blob
`3f5ec962` → `27d7355e`): the unit suites had 4 failed / 359 passed. The
template scan reported `approval-service.ts · resolveApproverSpec ·
${a.type}:${a.value}` as unclassified, and the fallback pin got
`expected [ 'role:admin' ] to deeply equal [
'org_membership_level:admin' ]`. The dogfood stayed green, as expected:
both fixtures route `type: 'position'`. Restored to `3f5ec962`.

## Tests and gates

All of these were run at `022bb0fa` (the round-0 head; the
plugin-approvals code half has not changed since):

- `plugin-approvals`, full: 59 files, 891 passed.
- The two dogfood files: 2 passed.
- `pnpm --filter @objectstack/plugin-approvals typecheck`: tsc, the
scripts project and `check:test-typecheck` all OK, with no new pinned
signature.
- `dispatch-gates --commands`: 95 derived commands. 94 exit 0 and 1
exits 1, `check-adr-0087-registration --base origin/main` (below). The
`--ran` reconciliation reads 95 derived, 95 run, 0 NOT-MEASURED, 0
UNRUN.
- Narrowed eslint over the 8 changed `.ts` files: 8 files, 0 errors, 0
warnings (`--format json`). All 8 are in the config's population
(`--print-config` resolves each). Type-aware linting is not enabled
anywhere in `eslint.config.mjs` (no `parserOptions.project`, as its own
header states), so this diff cannot move any untouched file's verdict.

## The ADR-0087 disposition (resolved)

_Resolved by the seat: verdict 5982629646 answered A, patch round 1
added the entry, and patch round 2 moved it to step 18. The round-0
analysis is kept below as written._

The changeset is declared-breaking: it has the `(narrowing)` arm and the
`!`. It therefore needs exactly one ADR-0087 marker. The gate reads the
ruling-mandated FROM → TO and the author's fix as a migration
prescription (`--list` shows `prescription=yes`). That refuses
`not-required (no-migration-prescription)` as a self-contradiction, and
`runtime-interface-only` inherits the same refusal. `unpublished` is
false, and no existing ledger id covers approval slot addresses, so
`already-registered` would be a dishonest claim. The honest disposition
is `registered` with a new D3 semantic ledger entry under
`packages/spec/src/migrations/entries/semantic/`. This is the pattern of
the ADR-0090 runtime faces `actor-user-roles-to-positions` and
`action-session-roles-to-positions`. That entry is a `packages/spec`
edit, and this card's claim and dispatch declare ⛔ no `packages/spec`
edit, so the marker is left absent rather than written false. The
question is in the dev report on the card.

## Acceptance notes

- **Pre-snapshot multi-approver requests.** A `unanimous`, `quorum` or
`per_group` request opened before the 17.0 open-time snapshot
re-resolves its slate at every tally (`decideNode`'s back-compat path).
If such a request has a deprecated `role` approver whose lookup was
empty, the re-resolved slot now spells `org_membership_level:X` while
the stored one is `role:X`, and the tally writes the re-resolved slate
back. Decidability is the same: only an admin can decide it either way.
One edge is new: an approval a position holder recorded under `role:X`
before the upgrade no longer satisfies the re-resolved slot, so the
request needs the admin override to finalize. This falls within the
accepted class of stored 15.x-era `role:` slots. It is the pre-existing
re-resolution, not a rewrite this PR adds. carrier: this PR, noted, not
filed.
- **Free-text slots are not covered.** A reassign's `to` and a `user`
approver's `value` are free text. A caller or author can still name the
literal `role:X` there, which makes a slot nobody takes. This is the
same pre-existing class as any `type:value` literal handed over there,
and the admin rescue covers it. The enumeration pin scans code literals
and templates, not data. carrier: none, noted, not filed.
- **Comment drift outside this surface.** These comments in other
packages now describe the old spelling:
- the `packages/lint/src/validate-approval-approvers.ts` docblock (about
:11) says the deprecated type "falls back to the `role:sales_manager`
literal". The literal is now `org_membership_level:sales_manager`.
- a `packages/rest/src/rest-server.ts` comment (about :12865) lists
`role:` among the console's identities.
- `packages/spec/src/contracts/approval-service.ts` (about :532) names
`role:` as a stored `acted_as` spelling. That is still true for
historical rows.

  carrier: whoever next touches each file. Noted, not filed.
- **Unreleased changesets disagree.**
`.changeset/21350-my-pending-position-address.md` and
`.changeset/21379-position-address-readers.md` are still unreleased, and
they describe `role:` as a second spelling. If they ship in the same
version as this changeset, both statements reach the CHANGELOG, and this
one's FROM → TO is the later fact. They are other cards' release inputs,
so they are not edited here.
- **How the refusals are asserted.** At the service layer the refusal is
asserted by the service's own code prefix (`FORBIDDEN:`), because the
service throws plain errors and the REST door assigns the status. The
`[status, code]` pair `[403, 'FORBIDDEN']` is asserted at the REST door
in both dogfood pins. A `role:` ask on the list door is a filter miss:
200 with no rows, not an error. Those rows assert the 200 and the empty
set.

## Patch round 1 (head 24f5c5a)

_Appended by the seat (`domain:services#1`,
`session_011K3zqE8Pv1Evw5hc8tZCnN`) from the dev's patch-round report
`5983095337`, after seat verdict `5982629646`. The opening paragraph
above was corrected in the same act._

Seat verdict 5982629646 answered the ADR-0087 question with A (surface
revision 2). The cross-lane declaration is on objectstack-ai#6017 (5982635753).

- **One D3 semantic ledger entry**,
`approval-position-address-role-retired`, in
`packages/spec/src/migrations/entries/semantic/17.approval-position-address-role-retired.ts`
_(moved to `18.approval-position-address-role-retired.ts` in patch round
2)_. It is modelled on `actor-user-roles-to-positions` and
`action-session-roles-to-positions`.
- Surface: the approvals position address `role:POSITION`, meaning the
`approverId` filter, a decision's `actorId`, and a stored
`pending_approvers` slot.
- Replacement: `position:POSITION`. An author's `{ type: 'role', value:
POSITION }` becomes `{ type: 'position', value: POSITION }`.
- Reason: the ADR-0090 D3 retirement; the deprecated `role` type's
fallback now writing `org_membership_level:VALUE`; the two request
classes that become admin-decided; no stored-slot rewrite; and why this
is a D3 entry and not D2 (the address is runtime data, and which type
the author meant is the author's judgment).
  - Acceptance criteria: what an upgrader verifies.
- **Generated with the repo's own tooling, never by hand:**
`gen:migration-registry` (the `registry.ts` region), `gen:spec-changes`
(`packages/spec/spec-changes.json`) and `gen:upgrade-guide`
(`docs/protocol-upgrade-guide.md`). `check:migration-registry`,
`check:spec-changes` and `check:upgrade-guide` exit 0. _(After patch
round 2, `spec-changes.json` and the upgrade guide are back to `main`'s
bytes: neither projects step 18.)_
- **The changeset's ADR-0087 marker** now reads `registered
approval-position-address-role-retired`, spelled from the gate's own
output. `check-adr-0087-registration --base origin/main` exits 0.
- **Nothing else changes in `packages/spec`:** no Zod schema, no
`ApproverType` alias, no contract docblock, no stored slot. _(Superseded
in patch round 2: the changeset now also names `"@objectstack/spec":
patch`.)_ The entry reaches 17.x upgraders through `os migrate meta
--from 17`, which composes step 18; measured on the built spec at
`d0a53cc6`, `composeMigrationChain(17, 18)` lists
`approval-position-address-role-retired`. `spec-changes.json` and
`docs/protocol-upgrade-guide.md` project only up to `PROTOCOL_MAJOR`
(17), so after patch round 2 they carry no row for it and are
byte-identical to `main`.
- **Hot file.** `origin/main` was merged at `3f4333ef`. objectstack-ai#21764 landed
after that, and `24f5c5a9` merges it: a clean merge that keeps both
sides. The three ledger checks, `check-adr-0087-registration` and
`check-changeset-no-major` exit 0 at `24f5c5a9`.
- **Tests at `3f4333ef`:**
  - the spec build;
  - the 58 spec test files that read the ledger: 2219 passed;
  - the CLI unit-tier ledger readers: 22 passed;
  - driver-sql `sql-driver-query-signature`: 15 passed.

Declared to CI: the full spec suite, which runs past this container's
10-minute foreground cap, and the CLI integration-tier ledger readers.
- **Gates at `3f4333ef`:** `dispatch-gates` derives 117 commands (22 new
spec families) and all 117 exit 0. `--ran` reads 117 derived, 117 run, 0
NOT-MEASURED, 0 UNRUN. CI on `3f4333ef`: 35 of 35 check runs completed,
`Check Changeset` success.
- **The Clause-② gate's path limb now hits `packages/spec/src/**`**, so
a contract review is owed. The seat runs it.

## Patch round 2 (head d0a53cc)

_Appended by the seat (`domain:services#1`,
`session_011K3zqE8Pv1Evw5hc8tZCnN`) from the dev's patch-round report
`5983807880`. Stale statements above were annotated in the same act._

The contract review of `24f5c5a9` (record 5983490043) returned FAIL on
two defects. The seat adopted it in 5983502467 and ordered this round.
Cross-lane addenda: objectstack-ai#6017 5983506965 and objectstack-ai#6024 5983511854.

- **The ledger entry moves to step 18.** `git mv` renames
`entries/semantic/17.approval-position-address-role-retired.ts` to
`18.approval-position-address-role-retired.ts`. The id and the content
are unchanged; the prose names no step-17 boundary, so it needed no
edit.
- The `step18` docblock says that a narrowing which lands after the
v17.0.0 cut is told "where `migrate meta` users are told, at the major
boundary where they look".
- `pnpm --filter @objectstack/spec gen:migration-registry` regenerated
`registry.ts`, and the entry now sits in the `step18` block. Against
`origin/main` (`025008ae`), `registry.ts` reads `1 file changed, 59
insertions(+)`.
- **The projections, regenerated and measured, not predicted.**
`check:generated` named two stale artifacts, `spec-changes.json` and
`docs/protocol-upgrade-guide.md`, and `check:generated --fix`
regenerated them from a fresh spec build. Both are now **byte-identical
to `origin/main`**: `git diff --shortstat origin/main HEAD` prints
nothing for either, and both have left the PR's file list.
- Step 18 is projected by **neither** generator. Both loop up to and
including `PROTOCOL_MAJOR` (17): `build-spec-changes.ts:254` and
`build-upgrade-guide.ts:78`. Neither file carries any `18.*` id; for
example, `ui-action-group-menu-members-typed` appears 0 times in each.
- **The channel, measured on the built `packages/spec` dist.**
- `composeMigrationChain(17, 18)` lists the entry. That is the chain `os
migrate meta --from 17` composes, because `CHAIN_TERMINUS_MAJOR` =
max(`PROTOCOL_MAJOR`, `MIGRATION_MAJORS`) = 18 and `MIGRATION_MAJORS` =
[17, 18].
  - `composeMigrationChain(16, 17)` no longer lists it.
- Control: the step-18 entry `ui-action-group-menu-members-typed` is
listed by `(17, 18)`.
- **The changeset now names `"@objectstack/spec": patch`** next to
`"@objectstack/plugin-approvals": minor`. Every other line is unchanged:
the `Clause-②: no (narrowing)` line, FROM → TO, the author's fix, the
admin handling, and the marker `registered
approval-position-address-role-retired`. `check-changeset-no-major
--base origin/main` exits 0 with `patch`. `check-adr-0087-registration
--base origin/main` exits 0: the id resolves at HEAD and is new in the
diff.
- **Bounded comment fix.** The header of
`packages/qa/dogfood/test/fixtures/my-pending-position-fixture.ts`
(about :10) no longer says "under both approver-address spellings". It
now says that `position:POSITION` lists the request and the retired
`role:POSITION` spelling (ADR-0090 D3) does not. The change is comment
only.
- Nothing else changed: no Zod schema, no `ApproverType` alias, no
contract docblock, no stored-slot rewrite, and no plugin-approvals code.
- **Hot file.** `origin/main` `025008ae` was merged at `d0a53cc6`. The
merge was clean, and main brought no change to `packages/spec`.
- **Gates at `d0a53cc6`:**
- `check:migration-registry`, `check:spec-changes`,
`check:upgrade-guide`, `check-adr-0087-registration --base origin/main`
and `check-changeset-no-major --base origin/main` all exit 0.
- `dispatch-gates` derives 116 commands. `check:generated` dropped out,
because no generated artifact is in the diff any more. All 116 exit 0,
and `--ran` reads 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN.
- **Tests at `d0a53cc6`:**
  - 57 of round 1's 58 ledger-reading spec test files: 2134 passed.
  - The CLI unit-tier ledger readers: 22 passed.
  - driver-sql `sql-driver-query-signature`: 15 passed.
- Declared to CI: `scripts/build-schemas-check-mode.test.ts` (it spawns
full schema builds and runs past the 10-minute foreground cap; it was
green in round 1's run at `3f4333ef`), the full spec suite, and the CLI
integration-tier ledger readers.
  - The plugin-approvals code half is unchanged and reused from round 0.
- **A fresh at-tier contract review is owed on `d0a53cc6`.** The seat
runs it once CI on this head has settled; landing waits for its PASS.

---
_Generated by [Claude
Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ace, meta-spelling and studio test titles state each cited decision in words instead of a tracker number (stage 14) (objectstack-ai#21799)

Part of objectstack-ai#20749
Clause-②: no

Stage 14 of this card, and the fifth area of class (e): the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the eight small directories together: `security/`,
`ai/`, `identity/`, `integration/`, `migrations/`, `marketplace/`,
`meta-spelling/` and `studio/`. Their 91 test-title and test-string
literals carried 96 tracker ids citing 65 records. 94 ids in 89 literals
now either state what their record decided, in words (form D), or are
dropped where the title already says it. Two ids stay, for the reason
given below. Text only: no assertion, identifier, test count or code
comment changes.

## Census at the base (`e83c9f6154`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`) and
`census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`),
byte-identical to the copies stages 10 to 13 used. A literal counts as a
test title when its folded message is argument 0 of a `describe` / `it`
/ `test` call, `.each` / `.skip` / `.only` chains included. Everything
else is an "other" string.

The worktree was cut from `origin/main` at `e83c9f6154`, one commit past
the claim's `8256a4b272`. That commit touches only
`api/error-code-ledger.zod.ts`, which is not a test file, so the test
census is the same at both.

Both instruments read **1414 messages / 1500 ids in 315 files**, the
seat's reading at `8256a4b272`. That is one more than stage 13's head
reading (1413 / 1499 at `72513933ee`), and the one id is in
`ui/component-props-unknown-members.pin.test.ts`. It moved from 1 / 1 to
2 / 2 when objectstack-ai#21764 (`4331a6b16c`, 2026-10-04T17:33Z) landed between the
two readings. That commit removed one id-bearing string and added two: a
`ruling:` value at `:322` that the assertion at `:417` matches with a
regular expression on its number, and a `describe` title at `:596`. It
joins the `ui/` stages.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 |
| `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| **`security/`** (this PR) | 8 | **28 / 28** | 28 / 28 | 0 |
| **`ai/`** (this PR) | 9 | **18 / 20** | 13 / 15 | 5 / 5 |
| **`identity/`** (this PR) | 6 | **15 / 15** | 14 / 14 | 1 / 1 |
| **`integration/`** (this PR) | 4 | **14 / 14** | 13 / 13 | 1 / 1 |
| **`migrations/`** (this PR) | 2 | **9 / 12** | 9 / 12 | 0 |
| **`marketplace/`** (this PR) | 2 | **3 / 3** | 3 / 3 | 0 |
| **`meta-spelling/`** (this PR) | 1 | **2 / 2** | 2 / 2 | 0 |
| **`studio/`** (this PR) | 2 | **2 / 2** | 2 / 2 | 0 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **315** | **1414 / 1500** | **1330 / 1412** | **84 / 88**
|

The eight directories read **91 messages / 96 ids in 34 files**, the
seat's figures.

- **Controls.** Lit, a title with three ids:
`migrations/migrations.test.ts:293`. Lit, an `expect` message:
`identity/api-key-retirement.test.ts:82`. Dark: the comment at
`security/permission.test.ts:309` ("The objectstack-ai#12497 refusal shape was
measured as") reads 0. Planted in a scratch copy of the head
`security/explain.test.ts`: an id put back into a title reads 1 / 1, and
an id put into a comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals in
seven of the eight directories. In `studio/` it reads 5 / 5 at the base,
because three literals in two test files are hex colours (`#7c3aed`,
`#2563eb`, `#94a3b8`). None matches the gate's pattern.
- **At the head:** 1325 messages / 1406 ids in 282 files. `ai/` reads 2
/ 2 (the two needles below), and the other seven directories read 0 / 0.
Nothing outside the eight moved. The wider pattern adds only the three
hex colours.

## How the area was chosen

Stage 10's rule ranks whole first-level directories by ids and takes the
busiest within about 10% of the ~100-id bound. `data/` (501), `ui/`
(416), `api/` (201) and `system/` (165) each exceed it alone, and the
files directly in `src/` (120) are 20% over. The eight small directories
read 96 together, within the bound. That is the group the stage-12 and
stage-13 ACCEPTs named, so the rule needed no second pass.

**Named for the next stages** (the head census, 1325 / 1406):
- `data/` 501 in five stages. It has one subdirectory, so the files
directly under it go in name order, in groups near the bound:
1. `aggregate-field-type-compatibility.test.ts` to
`default-value-tokens.test.ts`: 22 files, 109 ids;
2. `document.test.ts` to `filter-dotted-head.test.ts`: 21 files, 109
ids;
3. `filter-empty-operator.test.ts` to `hook-body.test.ts`: 21 files, 108
ids;
  4. `hook.test.ts` to `record-surface.test.ts`: 16 files, 107 ids;
5. `search-fields.test.ts` to `validation.test.ts` (8 files, 16 ids)
with `data/driver/` (7 files, 52 ids): 68 ids.
- `ui/` 416, about four stages.
- `api/` 201, two.
- `system/` 165, two.
- The files directly in `src/`, 120, one.
- The two needles left in `ai/build-progress.test.ts` and the one in
`contracts/approval-service.test.ts`. Each leaves only together with the
source docblock it pins.

## What each id became

29 literals (33 ids) now state a decision in words. 60 literals (61 ids)
drop a number the title already explains. Every cited record was read
with its comments through REST: 60 answer 200. objectstack-ai#6362, objectstack-ai#8715 and objectstack-ai#14676
answer 404, and their decisions were read from the landing commits.
`cloud#1967` and `cloud#2172` answer 403, because the cloud repository
is not attached to this session. `cloud#1967`'s decision was read from
what landed, and `cloud#2172` is one of the two needles that stay.

| record(s) | literal | now reads |
|:--|:--|:--|
| objectstack-ai#16870 | `security/explain.test.ts:413` | "the AUTHORING accept set
refuses a readScope beside viewAllRecords, the pair this snapshot shape
tolerates". The record refuses a depth axis beside the super-user bit
that short-circuits it. |
| objectstack-ai#17189 | `security/high-privilege.test.ts:30` |
"describeHighPrivilegeBits — an app-declared capability is not a
platform system permission". Ruling (i): a name on the stack's declared
capability list does not count as a system permission. |
| objectstack-ai#3391 | `security/permission.test.ts:545` |
"EffectiveObjectPermissionSchema (response side: the server-resolved
operations the UI renders)". The contract: the server resolves each
object's effective operations, and the UI only renders what it is
served. |
| objectstack-ai#6762 | `security/rls.test.ts:704` |
"RowLevelSecurityPolicySchema.using — the published description
advertises what the compiler lowers". The description was corrected to
the subset ADR-0058 widened. |
| objectstack-ai#12699 (2) | `security/tenancy-posture.test.ts:21`, `:50` |
"PlatformGlobalObjectsSchema — the objects a deployment exempts from the
Layer 0 wall" and "OrgScopingEntitlementSchema — the deployment facts
Layer 0 arming reads". |
| objectstack-ai#15813 | `security/tenant-layer0-verdict.test.ts:16` |
"TenantLayer0VerdictSchema — the four verdicts the wall records on an
operation". Ruling (i): `plugin-security` records the Layer 0 verdict it
computed, and the publish site reads it. |
| objectstack-ai#3820, objectstack-ai#3894 | `ai/agent.test.ts:74` | "agent.tools retirement
(ADR-0064) — tombstoned; tools move into skills". `agent.tools[]` was
removed, and the docs teach the action-to-skill path. |
| objectstack-ai#3278 | `ai/knowledge-source.test.ts:97` | An `it.each` row: "a
dialect the protocol does not declare (`js`, a retired expression
dialect, ADR-0058 addendum)". |
| objectstack-ai#7113 (2) | `ai/skill-trigger-condition-value-shape.test.ts:47`,
`:175` | "a set operator carrying a scalar is refused at authoring time"
and "the value-shape refinement does not disturb the carrier". The value
is shaped by its operator at authoring time. |
| objectstack-ai#3896 | `ai/skill.test.ts:195` | "retired `triggerPhrases` — phrases
never routed a skill; triggerConditions do". See the note below the
table. |
| objectstack-ai#8715 | `identity/api-key-retirement.test.ts:82` | A declared `expect`
message: "... must have zero holders after the ApiKeySchema retirement".
The record answers 404. `2c86fe3ea7` retired the fictional
`ApiKeySchema`, so `sys_api_key` has one declaration. |
| objectstack-ai#18509 (2) | `identity/identity.test.ts:88`,
`identity/organization.test.ts:132` | "UserSchema.image accept set —
null, the shape better-auth serves", and the same for
`OrganizationSchema.logo` (landed as `b9d5422142`). |
| objectstack-ai#11965 | `identity/platform-admin-capabilities.test.ts:10` |
"ADMIN_FULL_ACCESS_CAPABILITIES — the one platform-admin list
plugin-security imports". Choice 6A. |
| objectstack-ai#8681 | `identity/platform-admin-capabilities.test.ts:34` | "the
wildcard grants NO export — export stays an opt-in axis, pinned at the
declaration's new home". Direction (a): `allowExport` left the admin
sets' wildcard entry. |
| objectstack-ai#3017 | `integration/connector-provider-errors.test.ts:13` |
"connector provider upstream-unavailable classification — an unreachable
upstream degrades instead of aborting boot". Configuration faults stay
fatal. |
| objectstack-ai#4395 | `integration/connector.test.ts:244` |
"ConnectorActionSchema.effect — declares whether an action reads or
writes". The ruling: an optional read-or-write declaration the run
summary counts. |
| objectstack-ai#6362 | `integration/connector.test.ts:846` | "ADR-0010 protection
envelope — preserved, never silently stripped". The record answers 404.
The decision is read from `b5404f496f`. |
| objectstack-ai#14676 (2) | `integration/connector.test.ts:1163`, `:1195` | A
declared `expect` message, "... after the errorMapping retirement", and
"the errorMapping retirement is registered under ADR-0087". The record
answers 404. The decision is read from `13c48c2a55`, which retired the
eleven `connector.errorMapping` keys. |
| objectstack-ai#4722 | `migrations/migrations.test.ts:258` | "keeps `visible`
client-side only — the half the server-side item gate did NOT change".
The record made the server filter the nav entries inside `areas[]`. |
| objectstack-ai#4651 | `migrations/migrations.test.ts:268` | "still carries the
area-gate removal history the step exists to explain". Ruling B removed
the fail-open area keys. |
| objectstack-ai#5015, objectstack-ai#4610, objectstack-ai#5781 | `migrations/migrations.test.ts:293` |
"protocol-17 NotificationAction / EmbedConfig entry — stops republishing
the falsified zero-consumer claim". |
| objectstack-ai#4610 | `migrations/migrations.test.ts:299` | "finds the entry, and it
still explains the dual-source orphaning (anti-vacuity)". |
| objectstack-ai#5561, objectstack-ai#6844 | `migrations/migrations.test.ts:333` | "protocol-17
resumeAuthority default-flip entry — supportsPause is enforced now, so
stop asking for a hand-audit". |
| objectstack-ai#17594 | `migrations/migrations.test.ts:455` | "protocol-18
element:filter / element:form entry — the chain NAMES the bare node it
leaves standing". |
| objectstack-ai#19056 | `migrations/migrations.test.ts:555` | "every major the floor
move to 16 dropped is refused, by name". The maintainer's ruling raised
the migration support floor from 10 to 16. |

**Dropped only (61 ids):** objectstack-ai#123, objectstack-ai#3544, objectstack-ai#4001 (4), objectstack-ai#4641, objectstack-ai#4703, objectstack-ai#4737,
objectstack-ai#4911, objectstack-ai#5337, objectstack-ai#5481, objectstack-ai#5515 (4), objectstack-ai#5685, objectstack-ai#5955, objectstack-ai#6628, objectstack-ai#6698, objectstack-ai#6861,
objectstack-ai#6919, objectstack-ai#7113 (4), objectstack-ai#7319 (2), objectstack-ai#7990, objectstack-ai#8326 (6), objectstack-ai#8424, objectstack-ai#8715, objectstack-ai#9885,
objectstack-ai#11503, objectstack-ai#12497, objectstack-ai#12840 (2), objectstack-ai#14103, objectstack-ai#14676 (2), objectstack-ai#14825, objectstack-ai#15028, objectstack-ai#15680,
objectstack-ai#15813, objectstack-ai#16870, objectstack-ai#17425, objectstack-ai#17487, objectstack-ai#18728 (4), objectstack-ai#18978, objectstack-ai#20321, objectstack-ai#21260 (2),
`cloud#1967`.

- Each of these titles already states the decision it pins: for example
"the cap is 200: exactly 200 ids parse, 201 are refused (never
truncated)" for objectstack-ai#8326, or "unknown keys are rejected, not stripped" for
objectstack-ai#4001. In `skill-trigger-condition-value-shape.test.ts:130`, "(objectstack-ai#5685: no
stricter than the runtime)" became "— no stricter than the runtime".
- **`objectstack-ai#123`** in `ai/conversation.test.ts:294` (`'Support Chat - Case
objectstack-ai#123'`) is a placeholder that cites no record: objectstack#123 is an
unrelated broken-links report. The string is a fixture's session name,
an input only. No assertion reads it, so dropping the number moves
nothing.
- **`cloud#1967`** in `ai/solution-blueprint.test.ts:674`: the record
answers 403. Its decision is read from `3e3ecb0e8f` and its CHANGELOG
entry: the strict mirror the design model generates against carries the
applier's `SNAKE_CASE` constraints. The title already says "VALUE
parity".
- **`objectstack-ai#3896`** is the sharing-rule card (`POST /data/sharing/rules`
bypassing `SharingRuleSchema`). The skill title cited it as an "audit
close-out", the batch that removed `triggerPhrases` along with other
dead clusters. The title now states the decision that landed with the
key's tombstone (`ai/skill.zod.ts:386`) and its conversion entry
(`conversions/registry.ts:2744`): phrases were never matched, and
activation is `triggerConditions` intersected with the agent's
`skills[]`.
- **The `it.each` rows** at `ai/knowledge-source.test.ts:97` and `:98`
feed a `%s` placeholder. vitest 4.1.11 formats `%s` with `String(value)`
and does not truncate it (`@vitest/utils` `baseFormat`). Only `$name`
interpolation goes through the 40-character `objDisplay`. Both rows are
short anyway, and the name comparison below confirms both full names.

## The two ids that stay

`ai/build-progress.test.ts:236` and `:237` are
`expect(SOURCE).toContain('cloud#2172')` and
`expect(SOURCE).toContain('objectui#7388 block 2')`. They are not
titles. They are the expected values of assertions that read the
`ai/build-progress.zod.ts` docblock and pin that its liveness watch
names its two carriers (`:84-85`). Changing them needs a code comment
and assertion logic, which this claim excludes. They leave together with
that docblock's citations, like the
`contracts/approval-service.test.ts:274` needle.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. No `__snapshots__` directory exists under the
eight directories, and no `.snap` file is tracked under `packages/spec`.
- **Projects:** two touched files are listed in
`packages/spec/vitest.repo-tests.json`:
`ai/tool-confirmation-prescription-tense.pin.test.ts` and
`identity/position-delegatable-enforcer.pin.test.ts`. Both were run in
the `repo` project at the base and at the head, and the other 32 in
`local`.
- **By substring:** every old literal, plus a window around each id (263
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The hits are:
- **the plan's own siblings:** `unknown keys are rejected, not stripped
(objectstack-ai#4001)` in the four files this PR edits;
- **this card's later stages:** same-text titles in
`data/driver-nosql.test.ts:375`, `data/driver/memory.test.ts:548`,
`data/driver/turso.test.ts:172` and `ui/dashboard.test.ts:717` (`carries
its unit (objectstack-ai#15680)`), `data/object.test.ts:105` (`(objectstack-ai#5955)`) and
`ui/action.test.ts:1612` (`objectstack-ai#3896 close-out`). They are already in the
`data/` and `ui/` census;
- **comments and release text:** the comment at `ai/agent.test.ts:193`,
the `security/sharing.zod.ts:261` docblock, two
`packages/spec/CHANGELOG.md` entries and
`content/docs/releases/v17/17-0.mdx:326`. None reads a test title, and
none is this card's share.
- **Migration tooling and generated files:**
`docs/protocol-upgrade-guide.md`, `packages/spec/spec-changes.json`,
`packages/spec/src/migrations/registry.ts` and the 842 files under
`migrations/entries/`, together with `spec-changes.ts`, `chain.ts`,
`index.ts` and `types.ts`. Searched for every changed literal whole, at
the base and at the head (178 needles), they read 0 hits. The lit
controls `resumeAuthority`,
`ui-notification-action-embed-config-retired` and
`element-filter-and-form-node-refused` hit 5, 4 and 2 files.
`migrations.test.ts` finds each entry by its `id`, never by a title.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are
`ai/conversation.test.ts:294`, `ai/knowledge-source.test.ts:97` and
`:98`, `identity/api-key-retirement.test.ts:82` and
`integration/connector.test.ts:1163`.

- **Result:** 34 of 34 files SAME on all three legs, as predicted in
writing before the run. `ai/build-progress.test.ts` reads SAME with 0
changed.
- **Totals:** 89 changed literals, 84 titles and 5 declared. The diff's
`+` and `-` lines are exactly the 89 planned lines, and every file keeps
its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string keeping an
id VIOLATION; an undeclared `expect` message changed VIOLATION; a title
re-split into a `+` chain DIFF.

**Test counts:** the 34 files were run at the base, in a separate base
worktree at `e83c9f6154`, and at the head, with `--project local
--project repo`. Both sides read 911 / 911 passed, with the same count
and status sequence per file in 34 of 34. 310 full test names change,
and each equals the base name with the planned replacements applied (0
mismatches). No full name repeats on either side.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
34 touched files are in it, and no `*.test.ts` at all. The controls
`src/security/permission.zod.ts`, `src/ai/knowledge-source.zod.ts` and
`dist/security/index.js` are in it.
- In the built `dist/`, five new phrases and four old literals each read
in 0 files. The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `b364b8179b`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18358 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 34 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stage 13, and all 79 exit 0. `--ran` reconciles: 79 derived, 79
run, 0 NOT-MEASURED, 0 UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 34 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 34 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 178 changed lines.

## Acceptance notes

- **The two `build-progress` needles** stay with the
`ai/build-progress.zod.ts` docblock they pin. The
`contracts/approval-service.test.ts:274` needle is untouched, as the
claim required.
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec` finds 90 lines citing ids this PR handled, in 16
packages: `plugin-security` 28 (14 files), `rest` 20 (7),
`service-automation` 11 (7), `lint` 5 (4), `plugin-audit` 4 (3),
`runtime` 4 (2), `qa/dogfood` 3 (2), `plugin-hono-server` 3 (1),
`client` 2, `platform-objects` 2, `plugin-sharing` 2, `cli` 2,
`objectql` 1, `connectors` 1, `formula` 1 and `plugin-approvals` 1.
- **Code comments still carry ids** in these files and their sources,
for example `ai/agent.test.ts:193` and `security/sharing.zod.ts:261`.
Comments are not this card's share, and none is touched here.
- **`origin/main` moved** two commits past the base before this PR
opened (objectstack-ai#21780, objectstack-ai#21783). Neither touches `packages/spec` or any file
here, so nothing was merged. The gate reconciliation noted that two
baselines changed across them (`query-options-erasure`, `slot-lookup`).
This diff feeds neither, and the queue re-runs both on the merged
generation.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… params unless its type is api, with the action:button prescription (objectstack-ai#21855) (objectstack-ai#21869)

Fixes objectstack-ai#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:

- The maintainer's ruling A on objectstack-ai/objectui#10289
(`5825589480`), verbatim: "⛔ `params` never carries two shapes, and ⛔ no
new value-bag key is declared."
- objectstack-ai#21704 fork 5 A (`5979239990`) refused a
member's `properties.params` and kept the member's `params` as
`z.unknown()`, the button row's value schema.
- The member's own `properties` prescription in `component.zod.ts`
already pointed at `action:button` for static values.
- triage's answer on objectstack-ai/objectui#11638 (`5990495682`,
amended by `5991243780`) withdrew the other direction (the container
reading an object `params`) and filed this card for the gate half.

| | 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:

```text
`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 objectstack-ai#21702 / PR objectstack-ai#21712 and objectstack-ai#21464 / PR objectstack-ai#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.
objectstack-ai/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](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

2 participants