feat(spec,lint): let an action declare its bulk dispatch contract, and refuse a view that wires it the other way - #17912
Conversation
…d refuse a view that wires it the other way A list view can wire the same declared action two ways, and the two deliver opposite input to the same body: `bulkActions: ['<name>']` dispatches it once per selected row (that row's `recordId`, no `_selectedIds`), while a `bulkActionDefs` entry with `execution: 'aggregate'` makes one dispatch for the whole selection (`params._selectedIds`, no `recordId`). The action declared neither, so both mismatches failed quietly and in opposite directions, and nothing could catch either: `recordId` and `_selectedIds` are both builtin action params, so the ADR-0104 strict gate admits either bag. - `ActionSchema` gains `execution`, reusing `bulkActionDefs`' own `BulkActionExecutionSchema` rather than re-declaring its two values, so there is no second spelling to drift. Near-miss keys rename onto it; `mode` does not, because on an action `mode` is a declared key of its own. - `@objectstack/lint` gains `action-dispatch-contract-mismatch` (error), a reference-integrity suite member, naming the action, the view and both contracts, over every list tier. - No silent default: an undeclared action is checked against neither wiring. Existing sources are migrated by the ADR-0087 semantic entry `action-bulk-dispatch-contract-undeclared`. - The showcase's per-record / aggregate recalc pair now declares the contract its prose used to carry. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 3 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 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 a38ddd573a4719cc8766acec03ad27b85ae11c84 && git checkout a38ddd573a4719cc8766acec03ad27b85ae11c84
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7135cf057e90dbe6d9177aef17e525da7277f68b 3f04d9c4c1a96a26ff529a9a5bdc8ac4ddd7aa02 && git checkout -B drift-repro 7135cf057e90dbe6d9177aef17e525da7277f68b && git merge --no-ff 3f04d9c4c1a96a26ff529a9a5bdc8ac4ddd7aa02
node scripts/docs-audit/affected-docs.mjs --json 7135cf057e90dbe6d9177aef17e525da7277f68b
|
|
os-contract-review REVISE. The ruling is implemented faithfully and every contract judgement in the round holds up under independent re-measurement — Q1 through Q5 all confirm. What stands between this and a clearance is that the round's verification was narrowed to the two packages it edited, and a third package that reads Read against REVISE listR1 — BLOCKER, required context. Two edits in one file. Same assertion, same file, same line as CI job
Consequence the round must declare: the PR's file surface grows into R2 — re-measure what the failing shard never reached. R3 — minor, one edit. The inserted JSDoc lines in R4 — minor, one pin. Q1's asymmetry is pinned on one side only. See Q1. R5 — file, do not fix here. The page-component bulk tier. See Q4. R1 in depth — is
|
| corpus | bare-string | aggregate def | distinct | fan-out | aggregate | both ways |
|---|---|---|---|---|---|---|
examples/ at a9c64779046 |
22 | 2 | 13 | 11 | 1 | 1 |
hotcrm at c716a2ccb3d31574a1a238a590f3e331ddae0200 |
2 | 1 | 3 | 2 | 1 | 0 |
Identical to the PR's table in every cell, and the names match too: showcase_recalc_selection aggregate, showcase_zoo_visible_string both ways, and hotcrm's add_contact_to_campaign / create_campaign fan-out with mass_update_stage aggregate. I verified the clone's HEAD is that sha and that its package.json pins @objectstack/* 17.4.0.
The hotcrm zero is a reading, not an empty population. The same run over 242 scanned files returned non-zero in every neighbouring bucket, and the control proves the both-ways bucket can come back non-zero from this probe. A shallow clone is fine here because this is a tree question at a named sha, not a windowed history question.
And the census closes arithmetically, which is the check that catches a number nobody can act on: my fan-out list is showcase_mark_done, showcase_recalc_estimate and exactly nine showcase_zoo_* actions. This PR declares the first two plus showcase_recalc_selection, leaves showcase_zoo_visible_string deliberately undeclared, and hands nine to the migration. 3 + 1 + 9 = 13. The body's "nine" is exact, not approximate.
Q3 — was refusing a D2 conversion right?
Yes — and on the stronger of its two legs, which the round argues second.
Leg (a) is not rhetorical; it is structurally true at the seam that decides. packages/spec/src/conversions/stored.ts:73 builds a single-collection stack — applyConversions({ [collection]: [item] }, ...) — and hands that to the chain. So when an action row rehydrates there is no views collection in the stack at all, and when a view row rehydrates there is no actions. A transform that derives execution from view wirings has, at that seam, no evidence whatsoever: it either no-ops or invents a value. That alone disposes of the conversion, whatever flag is set on it.
Leg (b) is verified verbatim against the tree. conversions/types.ts:166-170: "Setting this does NOT confine a rewrite to history. For a conversion whose old and new shapes are both legal and mean different things (a default flip, not a rename), the data-at-rest seams will still apply it." apply.ts:132 is the only read of the flag and it is skipped under includeRetired; stored.ts pins includeRetired: true rather than offering it. The withdrawn precedent is at conversions/registry.ts:2022 and reads exactly as the PR describes it, including the scaffolded-app blast radius.
One citation correction, and it is not the PR's. The PR body says "#16864 on the mechanism", which is accurate. The landed code correction in apply.ts is excludeConversionIds (apply.ts:74-99), introduced by 134b410a90c for #17899 (fixing #17885); the types.ts jurisdiction docblock is 29dd1a6ddb1 for #17888. #16864 is the card the mechanism was measured on, not the commit that landed either file. Worth having straight because #17899's own reasoning is this PR's argument in the same registry: "the entry cannot answer the question. A machine-written pre-split row and an author who wrote hidden: true yesterday are byte-identical at the item level."
Does excludeConversionIds reopen the option? No. It lets a seam decline a default flip; it does not hand a per-item transform evidence it does not have. Leg (a) is untouched by it.
Is prose-plus-TODO what D3 provides? Yes, and the ruling's instrument could not have been anything else. SemanticMigration (packages/spec/src/migrations/types.ts:30-41) has no apply — it is prose by type. MigrationTodo extends SemanticMigration { toMajor } and MigrationHopResult.todos (types.ts:76-87) are the per-hop structured TODO the ruling asks for. So when the ruling said "an ADR-0087 semantic migration entry DERIVES", it named an instrument that is by construction incapable of running a transform; "DERIVES" can only mean the derivation RULE the entry states. The entry states it exactly and unambiguously (packages/spec/src/migrations/entries/semantic/18.action-bulk-dispatch-contract-undeclared.ts:9-19), carries the census with both shas as its input (:30-35), and its acceptanceCriteria (:36-46) makes the migrator prove each derivation against the body rather than assume it. The ruling demands nothing here that this PR does not deliver.
Q4 — does the lint refusal meet item 2 literally?
Yes, over the three list tiers that exist — with one tier boundary to file and one silence worth knowing.
The message at packages/lint/src/validate-action-dispatch-contract.ts:199-203 interpolates the action name, the view label, and both CONTRACT_PROSE sentences (:107-115) — the declared contract and the wired one, each with its dispatch count and which builtin key arrives. The where field is the view label plus · bulkActions (:233) or · bulkActionDefs[i] (:252); the label itself (:227) is view "NAME" · list, view "NAME" · listViews.KEY, or object "NAME" · listViews.KEY. Tiers walked: view list (:267), each view listViews entry (:271), each object listViews entry (:284) — the same three the established sibling walks (validate-action-name-refs.ts:209,213,230); objects have no top-level list.
I re-ran the pins rather than reading the list: 14 passed (14). It fires on both mismatch directions, and stays silent on: per-record declared wired bare-string; aggregate declared wired through an aggregate def; the showcase's real three-action shape; an undeclared action wired both ways; a data-plane operation: 'update' def whose button id collides with a declared action name; and a def carrying an inlined actionDef. Each is its own it, and the two skips are explicit at :245-246.
A silence not on the brief's list. collectDeclaredContracts (:160-164) drops any name whose declarations disagree — and it counts "declares no execution" as a disagreeing declaration. So a same-named undeclared sibling action (a global one plus an object-embedded one) silently disarms the rule for that name. It is deliberate, documented and pinned ("stays silent when two declarations of one name disagree"), and it is the zero-false-positive posture the suite holds. But it is a disarm, not a neutral case, and it is worth the seat knowing the shape exists.
R5, to file rather than fix here: the page-component bulk tier. packages/spec/src/ui/component.zod.ts:2598-2600 gives the object-grid page component bulkActions, bulkActionDefs and batchActions — the last described as "Alternate spelling the renderer reads FIRST". No member of the reference-integrity suite walks that container for bulk wirings: the name-ref sibling reads only properties.actionNames on page components. So a list rendered as a page component is outside this rule and outside the name-ref rule, and batchActions is a spelling neither has ever seen. Pre-existing and consistent — not a regression this PR introduces, and not its scope — but it is a real edge of the refusal's population and it should be a card rather than folklore. Those component props are z.array(z.unknown()), so nothing else refuses them either.
Suite wiring is correct: reference-integrity-suite.ts:343, membership pinned in reference-integrity-suite.test.ts. It takes the frozen flow runtimeTypes default, with the reasoning stated inline — so it runs on the full-stack path (os validate / os lint / os compile) and deliberately not on a per-write Studio snapshot, which carries no stack.actions to resolve against. Same choice as the sibling.
Q5 — cost direction, and the ADR-0104 claim
Ablation re-run by me, own script, own restore trap with absolute paths, from the committed state, under the verify lock:
| step | reading |
|---|---|
| HEAD blob of the rule file | 04113f029fa2fd511271c514ffd116683b2b96ed — on-disk identical, git status clean |
| baseline | 14 passed |
leg B — drop the matched-wiring early return at :187 |
anchor occurrences 1 to 0, injected 1, on-disk c9bee82ccd0f9c498668efeff778ccebff2f650c (differs from HEAD blob) |
| leg B verdict | **3 failed |
restore git checkout HEAD -- |
back to 04113f02…, git status --porcelain for that path empty |
| re-run | 14 passed |
Both hashes are byte-identical to the ones the PR body reports, which I did not have in hand when I computed them. The cost direction is real: the acceptance is pinned as hard as the refusal, and a later "strengthening" of this rule cannot quietly start refusing correctly-wired apps without turning three named tests red.
The ADR-0104 blindness claim is verified, with its control. ACTION_PARAM_BUILTIN_KEYS is ['recordId', 'objectName', '_selectedIds'] at packages/spec/src/ui/action-params.zod.ts:87, and validateActionParams seeds its allow-set from it unconditionally at :193. I ran the spec-side pin file: 8 passed (8). The one-underscore-off control lives in the same it (packages/spec/src/ui/action-dispatch-contract.test.ts:72-73): selectedIds comes back ['unknown_field'] while both builtin bags come back []. So the two silences are a reading and not a dead probe, and the reproduction correctly survives this PR — the gate is unchanged, which is exactly why the refusal had to land in lint.
Q6 — anything owed that is missing?
Changeset, artifacts and docs are all present and correct; the exit-3 reading is right; none of the acceptance notes is a blocker. What is missing is coverage, and that is R1/R2.
- Changeset
.changeset/17319-action-bulk-dispatch-contract.md:@objectstack/specminor +@objectstack/lintminor. Correct level — a new optional key on a published schema and a new rule are both additive; nothing here is breaking, nothing is patch-only.Check Changesetis green on the head. - Generated artifacts are consistent with exactly one added key:
authorable-surface/ui.jsongains one row (ui/Action:execution);liveness/action.jsongains oneliverow whose evidence names the single consumer and whose note states the deliberate non-enforcement at runtime;state-counts.mdmovesactionlive 43 to 44 and the total 861 to 862;migrations/registry.tsgains the semantic entry.Spec property livenessis green on the head. - Docs cover both surfaces, as item 4 requires:
content/docs/ui/actions.mdxgains the declaration section andcontent/docs/ui/views.mdxgains the cross-linked counterpart on the wiring side. Nocontent/docs/releases/path appears in the diff — correct. check:published-readme-exportsexit 3 — the reading is right, and nothing else hides behind it. 3 isEXIT_PREREQUISITE_NOT_MET(scripts/check-published-readme-exports.mjs:383) and it is returned up front (:2283-2286), before the type surface is built and before one document is judged — the script's own comment says it is refused there precisely so the state does not arrive under the exit code that means "a README is wrong". So it cannot mask a finding as a zero; it masks the whole measurement, which is what the body claims. The diff's only export-surface move is two added exports inpackages/lint/src/index.ts, andpackages/lint/README.mdnames onlyvalidateWidgetBindingsandvalidateStackExpressionsand is untouched — an additive export cannot falsify a README that was passing. That last sentence is an argument from the gate's direction, not a measurement; see below.- Acceptance notes. The dispatch brief says five; the body carries four, of which two are marked
noted, not filed. None is a blocker. The two design statements (the rule stops at the mismatch; the runtime does not refuse) are the ruling's own item 2 and its explicitly deferred sibling, and the liveness note says so rather than implying runtime enforcement.showcase_mark_doneis now declaredperRecord, so the misfire it describes became a build-time refusal in the same commit — genuinely nothing to file. The nine left undeclared are exactly the nine my census names, they are predicate-matrix specimens, and the migration entry is where they are owed.
What I did NOT measure
- The individual
check:*gates.Lint & Repo Gatescompletedsuccessafter I first read it, which covers them as a context — but I re-ran none of them myself, so the body's per-gate exit-0 table remains the round's reading, not mine. @objectstack/plugin-approvalsand@objectstack/downstream-contracton this head: never reached in CI, and my own attempt atdownstream-contractrefused on a prerequisite (@objectstack/clinot built in my worktree). I have no reading either way. That is R2, and it is the one place a fourth cross-package consequence could still be hiding.check:published-readme-exports— not run. It needs every package built and I did not build them.- The
npm pack --dry-runmeasurement, the whole-population eslint run, and the full@objectstack/specand@objectstack/lintsuites — not re-run. I ran the two pin files, the metadata-protocol file, and the ablation. - hotcrm's sha as a current tip — I confirmed the clone's HEAD is the sha the census names and that it pins
17.4.0; I did not verify that sha is that repository's default-branch tip today. - The renderer halves of both contracts — that a bare string really fans out N times and an aggregate def really dispatches once lives in the UI repo. I verified the claim's internal consistency across spec, lint, docs and the showcase; I did not drive a browser.
- Whether the
object-gridpage-component bulk tier is reachable in any shipped app — I found the schema keys, I did not census that surface. - My own served tier — see below. I could not certify it in a way this rulebook accepts as self-certification.
Tier statement
This is an in-seat at-tier review: the adjudicating subagent inherits the dispatching seat's session id, so it is ⛔ NOT an independent second seat, and it was dispatched with an explicit model parameter.
.claude/skills/pm-dispatch/references/contract-review.md is explicit: 「传参只是配置 ⛔ 不作达档读数」 and 「⛔ 自述档位不是读数」. The reading this round is required to take, before producing a verdict, is the harness-stamped per-message model field in this subagent's own transcript. I took it, and it does not equal CONTRACT_REVIEW_TIER as declared at scripts/pm/dispatch-gates.mjs:10176 — where that constant's own docblock states the comparison "is EXACT, never a family or prefix floor", and that widening the accept set is the maintainer's decision.
The reading is lit, not a dead probe: other subagent transcripts in this same session directory carry the contract-review tier's identifier in that same field, the most recent on 2026-09-12, so the value is served in this environment and the field can come back the other way. The constant itself was last set on 2026-09-08, so this is a genuine downgrade, not a drifted constant.
⇒ Per 「产出裁决的每轮都须读到契约复审档位,见回退证据 ⇒ 裁决整体作废」, this round cannot be the clause-② clearance for #17319, and it does not claim to be. A REVISE is the safe direction for a downgraded round — it clears nothing, and 「标签在复核完成前原样留置,卡在队列外等待是安全态」. I have touched no label, submitted no review, flipped no state.
So two things are owed, not one: the R1-R5 revisions, and then a re-review at the contract-review tier with the transcript check passing, before the dual carrier comes off. The rulebook's remedy is already written — 「改走转录核验的复核子代理」 — so this is a re-dispatch, not a maintainer escalation.
Generated by Claude Code
…he new key, and pin the def-side `mode` alias Three repairs from the contract-review round, no production behaviour changed. - `protocol.meta-types-degenerate-derivation.test.ts` pinned `action` at 47 served top-level properties. This branch's single new key on the action schema (`execution`) takes the served count to 48, so the pin and the two docblock sentences quoting it move to 48. The count is a descriptive reading, not the anti-widening invariant: that invariant is the separate blast-radius assertion, whose baseline is recomputed live and which passes unchanged on this head — it still reads `moved === ['action']`, so exactly one served type moves and the derivation was not widened. - `bulk-action.test.ts` gains the def side of the alias asymmetry: `BulkActionDefSchema` renames `mode` onto `execution`, the one entry its alias table has and `ActionSchema`'s deliberately does not. The action side was already pinned; the def side was not, so deleting the alias reds nothing while falsifying the comparison the action schema's comment makes. - `field-zoo.view.ts` JSDoc block: nine inserted lines sat at 1-space indent inside a block indented 5. Comment whitespace only; ESLint does not judge it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…tion-dispatch-contract
…17501 docblock The narrative docblock above the degeneracy retry said `/meta/types` served an empty schema for "a type that accepts 47 keys". This PR's own new key on `ActionSchema` takes that served property count to 48 — the count this branch already moves at three sites in the sibling suite (`protocol.meta-types-degenerate-derivation.test.ts`), so the src twin was the last confidently wrong copy of the number. Comment-only, zero behaviour. No changeset: `@objectstack/metadata-protocol` publishes `files: ["dist","README.md","CHANGELOG.md"]` — no `src` entry — and the prose reaches no published byte (measured: 0 hits in `dist/`, and `sourcesContent` is dropped from the sourcemaps). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…tion-dispatch-contract
Fixes #17319
Clause-②: yes — a new authorable key (
execution) on a published schema (ActionSchema).needs:contract-reviewis on this PR from the moment it opened; ⛔ not flipped out of draft.Implements the ruling verbatim (director seat, decision batch #121 item 3, 2026-09-12; maintainer: 「同意」). B and C are not reopened here: B ("unify the two wirings to one shape") cannot unify the essential difference — one call versus N calls have different side effects — it can only hide it, and it changes deployed behaviour silently; C leaves the mismatch silent.
The spelling adopted, and where it was read from
bulkActionDefsalready names the two dispatches. Read frompackages/spec/src/ui/bulk-action.zod.ts:⇒ the action's declaration is the same key
executionwith the same two values'perRecord' | 'aggregate', and it is that same schema object, imported rather than re-declared:That is what makes the ruling's ⛔ no-third-spelling structural rather than remembered: there is no second enum to drift, and the filing card's proposed
dispatch: 'per_record'survives only as an alias on the KEY (dispatch,dispatchContract,bulkExecution,bulkDispatchall rename ontoexecution) — the snake_case VALUE is refused. ⛔modeis deliberately NOT aliased, the one place the two surfaces' alias tables differ: a bulk def aliasesmodeontoexecution, but on an actionmodeis a declared key of its own and renaming it would eat a real declaration.The census — the migration's input (the seat's open number)
Probe: an AST walk (TypeScript's own parser), counting structures, not lines — a
PropertyAssignmentnamedbulkActionswhose value is an array literal contributes one per-record wiring per string element; one namedbulkActionDefscontributes one entry per object literal, classified by that entry's ownexecution. A picklist option, a prose mention and a comment are structurally invisible to it.Lit control (it could have come back the other way): a fixture carrying one bare-string wiring, one aggregate def, one action wired both ways, one
operation: 'update'data-plane def, plus a picklist option whose VALUE is'aggregate', a string of prose spellingbulkActions:and a commented-out wiring. The probe returned per-record 2 / aggregate 2 / one action classifiedwired-both-ways/ one data-plane def not counted as an action wiring, and ignored all three negatives. Every bucket the census reports is a bucket the control proved the probe can reach.examples/, ata9c64779046)objectstack-ai/hotcrmatc716a2ccb3d31574a1a238a590f3e331ddae0200(pins@objectstack/* 17.4.0)Both halves are MEASURED; hotcrm was cloned for this and is not reported from memory. Test fixtures are excluded from both counts on purpose (the repo-wide figure including
packages/lint's andpackages/spec's fixtures is 41 / 8 / 27 distinct, which measures our test corpus, not anybody's app).Named, so the migration has something to act on:
showcase_recalc_selection.showcase_zoo_visible_string—list.bulkActionsandlistViews.predicate_columns.bulkActions(per-record) pluslistViews.inline_bulk_defs.bulkActionDefs(aggregate). Its body readsctx.recordIdANDinput._selectedIdsand copes with either, so it stays undeclared and a comment now says why. That is the honest use of the omission, and the reason no third enum member was invented for "both".add_contact_to_campaignandcreate_campaignfan-out,mass_update_stageaggregate — 0 ambiguous, so hotcrm's ~60 lines of hand-copied contract prose convert to three declarations with no judgement left over.What the migration derives, and what it hands back
New ADR-0087 semantic entry
action-bulk-dispatch-contract-undeclared(protocol 18 step). It derives: an action wired one way has been receiving that shape all along, so writing the declaration down changes no behaviour — bare-string ⇒'perRecord', aggregate def ⇒'aggregate'. It hands back a structured TODO where an action is wired both ways, because there is no correct value there: either the body splits into two actions along the line the two wirings already draw, or it was deliberately written for both and stays undeclared.retiredFromLoadPath: truedoes not hold a transform toos migrate meta— the artifact-ingestion door replays the whole chain withincludeRetired: true, which is exactly how the withdrawnfield-required-notnull-explicitconversion handed every scaffolded app constraints it never asked for (the ⛔ WITHDRAWN block inpackages/spec/src/conversions/registry.ts, and #16864 on the mechanism). A conversion here would therefore be the silent default the ruling forbids, arriving through a door nobody was looking at.The refusal, and both directions of it
@objectstack/lintgainsvalidateActionDispatchContract/action-dispatch-contract-mismatch(severityerror), wired as a reference-integrity suite member so it runs onos validate,os lintandos compileat once, over every list tier (a view'slist, eachlistViews.KEYentry, and an object's ownlistViews). Real message, for the showcase's own pair:It names the action, the view and both contracts — the declared one and the wired one, each with what it actually delivers — and the hint offers both ends of the fix, because which end is wrong is the author's call.
Pinned in both directions, and the acceptance is pinned as hard as the refusal: aggregate-declared wired bare-string ⇒ refused; per-record-declared wired through an aggregate def ⇒ refused; per-record-declared wired bare-string ⇒ clean; aggregate-declared wired through an aggregate def ⇒ clean; the showcase's real shape (three actions, both wirings, one list view) ⇒ clean.
⛔ No silent default, stated where it is enforced
executionis optional and an action that omits it is undeclared, never defaulted — 「创业阶段不渐进」. The rule has nothing to judge on such an action, including one wired both ways, and pins say so. This is also why existing apps are untouched: the refusal's population grows by migration, never by guess.Reproducing the defect, and verifying the card's claim about ADR-0104
The card says the strict params gate structurally cannot see this because
_selectedIdsis a builtin. Verified, not assumed:validateActionParamsbuilds its allow-set fromACTION_PARAM_BUILTIN_KEYS = ['recordId', 'objectName', '_selectedIds']and skips every member unconditionally, so the two bags the two wirings produce differ in exactly the keys it must wave through.packages/spec/src/ui/action-dispatch-contract.test.tsreproduces it: the same resolved action,{ format, recordId, objectName }and{ format, _selectedIds, objectName }, zero issues for both — with a control in the same test (selectedIds, one underscore off) that IS refusedunknown_field, so the two silences are a reading and not a dead probe. That reproduction is kept, because this PR adds an authoring declaration and does not change the gate.Ablation — mutate on disk, prove it landed, read the verdict, restore, prove the restore
Two legs, one anchor (
if (declared === undefined || declared === wired) return;), each proved by occurrence count ANDgit hash-objectbefore its result was read. HEAD blob of the rule file:04113f029fa2fd511271c514ffd116683b2b96ed.vitestgit diff HEADemptydeclared === wired→true(rule never fires)9881ddd6…≠ HEAD blobgit checkout HEAD --04113f02…== HEAD blob, diff emptyc9bee82c…≠ HEAD blobgit checkout HEAD --04113f02…== HEAD blob, diff emptyLeg B is the one that matters for blast radius: the three tests it turns red are the correctly-wired ones, so they are not vacuous and a later "strengthening" of this rule cannot quietly start refusing valid apps. Both legs ran from the committed state, restored with
git checkout HEAD --(never a baregit checkout --, which would take the mutation back out of the index), under atrap … EXIT INT TERMwith absolute paths; the working tree is clean.Repair round —
packages/metadata-protocolenters this PR's file surfaceA bounded repair list from the contract-review verdict. Three edits, +23 / -12 over three files; no production code, no schema, no behaviour changed.
The surface growth the original round did not declare:
packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts— one file, tests only.That suite pins how many top-level properties
GET /meta/typesserves foraction. This PR adds exactly one authorable key toActionSchema(execution), so the served count moves 47 to 48 and the pin fails. The redTest Core (2/6)shard is this PR's own doing, reproduced locally before any edit:AssertionError: expected 48 to be 47at line 150;1 failed / 17 passed (18)18 passed (18)Moving the number does not disable the invariant — checked, not assumed. The file's own docblock states which assertion is the anti-widening guard, and it is not this count: it is the separate blast-radius assertion, whose baseline is recomputed live from the raw output derivation "so the assertion keeps meaning as schemas evolve". It reads
expect(moved).toEqual(['action'])— at most one served type may differ from the pre-fix derivation. It sits among the 17 that passed in the red run, before any edit of mine. So the degeneracy gate was not widened into a blanketio: 'input': one reviewed key moved one type's count, which is the motion this count exists to track. The two sibling pins agree — every other type stayed byte-identical, and all thirteen positive-control counts held.Three sites carry the number and all three move together: the assertion (line 150) and the two docblock sentences quoting it (lines 5 and 26). The repair list named two; the third sits in the same docblock and would otherwise leave it asserting 47 against its own pin of 48. Named here rather than smuggled.
Changeset: none owed — measured, not inherited. All three edited files were checked against what their packages actually publish.
@objectstack/metadata-protocolshipsfiles: ["dist","README.md","CHANGELOG.md"]and@objectstack/specshipsdistplussrc/**/*.zod.ts— a glob that does not match a.test.ts. Grepping the built surfaces for symbols unique to the edited files returns 0 hits (preFixDerivation,CARD_PROPERTY_COUNTS, and both new test titles), against a positive control in the same reading that does hit (BulkActionDefSchema26,getMetaTypes4); no*test*file exists anywhere under eitherdist.examples/app-showcaseisprivate: true. Nothing published moves, soCheck Changesetstaying green is a measurement here, not an inheritance.The two packages the failing shard scheduled but never reached
Test Core (2/6)stops at its first failure, so these two were unmeasured, not green. Both were run on this head and are now real readings:@objectstack/plugin-approvals@objectstack/downstream-contract@objectstack/downstream-contractneeds@objectstack/clibuilt first. The closure build ran to exit 0 before the suite, so this is a result and not a prerequisite refusal. Neither package went red; no new finding from them.Repair-round verification
metadata-protocolfull suitespecprojectlocal(post-merge)specprojectrepo(post-merge)lintsuite (post-merge)spectypecheck (incl.check:test-typecheck)tsconfig.test.json --listFilesdoes reachsrc/ui/bulk-action.test.ts, so this covers the new test rather than merely scheduling itmetadata-protocoltypecheck--listFilesreaches the edited test fileexample-showcasetypecheckTS2307, then 5, all "cannot find module" on unbuilt workspace deps and none naming the edited file; reported as PREREQUISITE NOT MET until the dependency closure was built, then a real 0eslint . --no-inline-config— whole population, not a narrowingcheck:nul-bytes·check:doc-authoring·check:cross-package-test-inputs·check:test-source-alias·check:examples-live-imports·check:merge-driver·check-comment-mask-adoption·check-comment-mask-corpus·check-keyed-text-boundsorigin/mainmergeR4 ablation — the new pin can actually fail. Deleting
mode: 'execution',fromBulkActionDefSchema's alias table: file blob17f99d36toaaa4390e(mutation proven on disk, anchor count 1 to 0), suite goes exit 1 on exactly the new assertion, and the message shows why — without the alias the rejection loses its rename hint and degrades to a bare unrecognized-key error. Restored blob back to17f99d36, anchor 1,git diff HEADempty, suite exit 0 with29 passed (29). Before this round the file had 28 tests and deleting that line reddened nothing.origin/mainwas merged once as a merge commit (never a rebase); it brought 15 files, none underpackages/spec,packages/lint,packages/metadata-protocolorexamples/app-showcase, and left noos-regendebt.Verification
pnpm --filter @objectstack/spec test(projectlocal)pnpm --filter @objectstack/spec test:repo(projectrepo)pnpm --filter @objectstack/lint testpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/lint typecheckpnpm --filter '@objectstack/lint^...' build(dependency closure)pnpm exec eslint . --no-inline-config— the WHOLE population, not a narrowingcheck:authorable-surface·check:docs·check:api-surface·check:migration-registry·check:spec-changes·check:upgrade-guide·check:liveness·check:generatedcheck:nul-bytes·check-adr-0087-registration·check-reference-carrier-shape(+--self-test) ·check-empty-changeset·check-changeset-no-major·check-doc-frontmatter·check-docs-section-name·check-comment-mask-adoption·check:select-gate-families·check:error-code-casingcheck:published-readme-exportsArtifacts regenerated, never hand-edited:
authorable-surface/ui.json,liveness/state-counts.md,src/migrations/registry.ts(viagen:migration-registry),content/docs/references/**.Changeset — measured, not assumed
.changeset/17319-action-bulk-dispatch-contract.md,@objectstack/specminor +@objectstack/lintminor (both additive: a new optional authorable key, a new rule). Measured against a realnpm pack --dry-run --jsonfor@objectstack/spec: 2012 entries, of whichsrc/ui/action.zod.ts,liveness/action.json,liveness/state-counts.mdand 216dist/entries — so an edited file this PR touches ships literally, not only compiled. Controls in the same reading:src/ui/action-dispatch-contract.test.tsand.changeset/**are ABSENT from the pack, so the presence readings are a measurement and not a listing of the whole tree.@objectstack/lintpublishesdistonly and itstsupentry list is['src/index.ts', 'src/runtime.ts']—src/index.tsis edited here, so its published surface moves too (argued from the declared entry, sincepackages/lint/distis not built in this checkout; ⛔ not claimed as a pack measurement).验收备注
noted, not filed:showcase_mark_done's body throws'No record to mark done'when it has norecordId— literally the card's second misfire direction, in our own reference app. It is now declaredperRecord, so the mistake is a build-time refusal; nothing to file. 承接者: the next card to touchexamples/app-showcase/src/ui/actions/index.ts.noted, not filed:the nineshowcase_zoo_perm_*/showcase_zoo_*_gateactions are left undeclared. They are fan-out by wiring and the migration would deriveperRecordfor all nine, but declaring them here would be scope this card did not ask for, and they are predicate-matrix specimens rather than dispatch specimens. 承接者: the ADR-0087 migration run, which names them.Generated by Claude Code