Repository navigation
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
Conversation
…the action container members (#21464) Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
… rows (#21464) Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…ts (#21464) Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 9 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
…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>
… 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>
Fixes #21464
Clause-②: yes (narrowing)
What this does
The S-final stage of the
ComponentPropsMapz.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 record5978663135, batch #276), forks 4 and 5 letters B and A (record5979239990, batch #277), per the claim5981629450. Read points are at the.objectui-shapin2e818d0b51ec, under objectuipackages/unless named. Every cited reader file is byte-identical at objectuimain2abec3a96(the pin is an ancestor of it;plugin-timeline/src/renderHandoff.tsandtypes/src/data-display.tsdiffer there in comments and a typed click slot only, not in either arm).object-metric·drillDown.reportz.unknown()ReportSchema, by referenceobject-timeline·itemsz.array(z.unknown())variantby a row refinementaction:group·actions[]z.record(z.string(), z.unknown())per memberaction:button's keys bytype, plus the inline button'ssizeaction:menu·actions[]sizeWith these three typed, the enumeration pin's
forklines are gone, theforkstage is gone with them, and a new §6 pins the close-out: no stage is declared and no ledger line isstaged.Fork 1 B —
drillDown.reportisReportSchemareportto the shared drawer verbatim (plugin-dashboard/src/ObjectMetricWidget.tsx:742).DrillDownDrawer.tsxdraws it as areportnode whenisDatasetBoundReportholds (:92, used at:115) — a non-emptydataset, or ajoinedreport with a block that binds one — joining the metric's filter into the report's ownruntimeFilter(:150-153). Any other value lists the records. objectui types the member as this package'sReportSchemaauthor input (SpecReportInput). Both files are byte-identical fromab1879721595, where the fork was measured.type: 'joined'report whose blocks bind nodatasetparses and passesobjectstack validate, while ReportSchema's own refinement comment and reports.mdx say "each block dataset-bound" #21702 (ed15448217) the joined arm refuses every block with nodataset, and every other type needsdatasetandvalues. The new pin's §4 restatesisDatasetBoundReportfrom 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).dataset, a report with noname/label, and a summary report with novalues. No measured writer authors any of them.drillDown's other members, stage 5's.enabled,title,target,columnsandmaxRowsstay the chart drill-down's by reference, andfilter/modestay refused by name. The block's describe and docblocks now sayreportisReportSchema. The metric family pin's §3 key set is unchanged.ReportSchema's defaults (type,drilldown) materialize on parse, soObjectMetricPropsParsednow also differs from the authored type ondrillDown.report. Its docblock says so. A page component'spropertiesis 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
time,title,description,variant,icon,contentandclassName(plugin-timeline/src/renderer.tsx:1612-1659vertical,:1697-1716horizontal). The gantt branch reads a row'slabel(:1899-1900) anditems(classifyGanttRows,:617-621; drawn at:1908), and each bar'sstartDate,endDate(:1909),variant(:1916) andtitle(:1918,:1921). That is exactly objectui'sTimelineFeedItem(seven keys) andTimelineGanttItem/TimelineGanttItemBar, taken as ruled.TimelineItemSchemashape. A union would fold an off-shape bar's issue into oneinvalid_unionat the entry, as objectui's docblock records.objectTimelineItemsFitVariant, restating objectui'stimelineItemsFitVariant) pairs each entry with the arm the row'svariantselects (absent meansvertical). It refuses, each at the key it names: the arm's required key absent (titleon a feed entry,labelon a gantt row), or a key only the other arm declares. The arm key lists are read off the two shapes, never restated.contentis opaque, by the ruling:z.unknown(), its describe says "Held opaque", and the enumeration pin records it under a newopaquereason naming the ruling record (§2 checks both). It is not a slot position.z.number()refusesInfinityandNaN). TheDatearm is left out, by the ruling.color,startDate,endDate,group,metaon an entry) are refused, each with what an authored entry writes instead.dateis an alias fortime.Fork 5 A — the container members, measured from the reads
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) andauto-trigger.ts(:96). It is not transcribed fromUIActionSchema. All four files are byte-identical fromab1879721595and at objectuimain.label(orname),icon,variantandtags(separator-before, the one tag read,:224/:408).visibleanddisabled.locations(actionRendersAton the group,:304).type,name,label,description,target,openIn,method,params,bodyExtra,bodyShape,operation,patch,confirmText,successMessage,errorMessage,refreshAfter,locations,toast,resultDialog,onSuccessandobjectName.action:button's keys bytype, with two differences the reads decide, and §3 derives the key set fromActionButtonPropsSchema.shapeto pin them.undoableandrecordIdFieldare not forwarded by either container.tagsis drawn by both.sizeis read only by a group's inline button (:124,mddrawn asdefault); anaction:menuitem reads none and declares none (theaction:iconprecedent).visible/disabledtake the rows' ownactionCondition(), and §3 checks the same accept set and the same envelope. The runner-forwarded blocks stayz.unknown()with "forwarded to the runner" in their describes, so the enumeration pin'srunnerreason holds for each.actionType→type(the rows' alias table, turned round);endpoint/url/path/href→target(the rows'ACTION_TARGET_ALIASES);enabled(the rows' own text) andautoTrigger(the rows' text onaction:menu; onaction:group, which never reads it, the text says so);outcomeMessages→successMessage;className→variant, or the node's ownclassName;properties→bodyExtra, or the action as its ownaction:button;undoable/recordIdField;action:menumember'ssize.outcomeMessagesstays undeclared on all four action blocks. §3 pins that none ofaction:button,action:icon,action:group,action:menuand neither member shape declares it. Theaction:button/action:iconrows 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,
.mapover a constant list, templates and same-file helper calls evaluated. It reads:typealso through a spread constant);schema={…}, orObjectMetricWidget's own props);report/items/actionskey 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
drillDownpopulation exactly (26 values) and the S-objectui-held census's 40action:group/ 19action:menuvalues.1289925c0a(the base;mainmoved 5 commits to33f97917ac, merged here throughos-regen-merge.sh, none touching a census corpus file):items(component-element-navigation-17987.test.ts:213, a feed entry), which parses;component-action-element-rows-20371.test.ts), which parse, plus that file's two probes the old row already refused;2e818d0b51ecand atmain2abec3a96, the same counts at both:drillDown.report: of the 26drillDownvalues, 2 carry areport. The drawn one (objectMetricDrillDownMembers-8071.test.tsx:281) parses. The other is that file'sit.eachpair (:305), the two values the drawer does NOT draw, both refused. The loose pass's 34reportkeys: 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 novaluesindrill-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,falseandnullbar 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:group40 values (26 parse, 2 refused, 12 partly non-static) andaction:menu19 (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:className(action-group-menu-inputs-11168.test.tsx:249);outcomeMessagesforward tests (action-outcomeMessages-forward-11344.test.tsx:147,:158);properties.paramsstatic-value tests (action-container-member-params-10290.test.tsx, read by hand);autoTriggerflag (action-overflow-autotrigger.test.tsx:298, and as a test device inaction-onSuccess-forward.test.tsx:182,action-objectName-onClick-4202.test.tsx:127andaction-menu-host-disabled-11182.test.tsx).The partly non-static members are predicate variables, helper-built members and code-composed
onClickfunctions, 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 — toaction:menuat run time, never through the component-props gate, and those members areActionSchemaentries (the ruled-out option C). Recorded, not a block writer.4054ec2680and cloud at2205b53010: no writer of any of the three. hotcrm's fourobject-metrictiles declare nodrillDown.Release
.changeset/21464-component-props-report-items-action-members-typed.md:'@objectstack/spec': minor, the BREAKING banner,Clause-②: yes (narrowing), the ADR-0087registeredmarker naming the three ids, a FROM → TO table and the census.18.ui-object-metric-drill-down-report-typed.ts,18.ui-object-timeline-items-typed.tsand18.ui-action-group-menu-members-typed.ts. The semantic region was regenerated bygen:migration-registry.RETIRED_KEYS_BY_MAJORrow: page-componentpropertiesis not on the save or load path, and no declared key is removed.content/docs/references/ui/component.mdx(two member tables and the timeline entry table) and the strictness counts (ui/204 → 208,component.zod.ts74 → 78: the entry, the bar and the two members).dropped-refinements.baseline.json670 → 676.ObjectMetricPropsgains the fiveReportSchemarefinement sites carried by reference, the S-forms growth-by-reuse precedent.ObjectTimelinePropsgains its root, the new pairing refinement.cel-action-member-visible(fail-closed) andcel-action-member-disabled(fail-closed), for the two new positionsactionContainerMemberShape.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
@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 oldz.array(a control). On this branch all 34 are refused.component-report-items-action-members-typed.pin.test.ts:ReportSchema.parse(report);codeandpath(the red-first set), plus the prescriptions;outcomeMessagesabsent on all six faces, the shared condition);ast, the runner-forwarded members, the report's tworuntimeFilterpositions, the opaquecontent), 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 equalsReportSchema's answer (noexpectremoved, no skip).pnpm --filter @objectstack/spec testatb7335d8374: Test Files 615 passed (615); Tests 18358 passed, 1 todo.pnpm --filter @objectstack/spec typecheckat the same head: exit 0, test layer held (52 files / 246 errors / 135 signatures; the touched pins are ontsconfig.test.json).pnpm --filter @objectstack/lint test: 119 files, 5627 tests passed. The showcasevalidate: passed. Dogfoodtest/expression-conformance.test.ts: 7 passed.scripts/ablation-replace.mjsin a driver with an EXIT / INT / TERM trap, restored and proven by blob hash (HEAD blobb4dcaf34d2,git diff HEADempty after every leg):report→z.unknown(): 17 red;itemselement →z.unknown(): 18 red;action:groupmember → an open record: 15 red;action:menumember → an open record: 13 red.lint'svalidateComponentProps. Nothing is reported for the drawn report drill, feed entries or group members.component-props-invalidis reported atdrillDown.report.dataset/drillDown.reportand atitems.0.title/.label/.items.component-props-unknown-keyis reported for an entry'scolor, a member'sactionTypeand a menu member'soutcomeMessages.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsgives 114 commands (merge base33f97917a, 14 paths, 1705 changed lines). All 114 exit 0 atb7335d8374, and the--ranreconciliation 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 afterturbo run build --filter=!@objectstack/docs(72 successful).pull_requestpayload (--event):check-changeset-no-major(LEVEL AXIS:yes (narrowing), no moved package gradedpatch),check-adr-0087-registration(one declared-breaking changeset,registeredwith the three ids) andcheck-empty-changeset, each exit 0.pnpm lint:eslint --no-inline-config --format jsonover the 10 changed.tsfiles reads 10 files, 0 errors, 0 warnings. The population is the repo's oneeslint.config.mjs, which ignored none of the 10. The narrowing excludes nothing: that config never enables type-aware linting (eslint.config.mjs:327-328, noparserOptions.project), so this diff cannot move a verdict on an untouched file.pnpm lint, reason: CI-owned. objectui was read at the pin and atmain, not built against this spec.Acceptance notes
Noted, not filed:
paramsis forwarded as the request payload of atype: 'api'member only. On any other type the container drops it with a development warning (static-params.ts:172-183). The member keepsparamsas the rows do (z.unknown(), runner-forwarded), and its describe says so.action:barhand-off above composesaction:menumembers from the host's registered actions at run time. Those carryActionSchemakeys (outcomeMessages,order,component, …) the member shape refuses. No gate judges that composition, and theaction:buttonrow's docblock already records the same member path foroutcomeMessages.action-block-endpoint-to-targetrewrites a storedendpointonaction:button/action:iconnodes only. A stored memberendpointis not rewritten. The census found no writer of one, and the refusal carries the rename.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.mainas2abec3a9, not yet at the.objectui-shapin) 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
action:button/action:iconrow edit, and noview.zod.ts/report.zod.tsedit.Generated by Claude Code