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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…tion-dispatch-contract
…uthoring door (objectstack-ai#17917) Fixes objectstack-ai#17425 Clause-②: yes — **flipped by the `domain:spec` seat, 2026-09-13T02:5xZ.** The round declared `no` and correctly left the final value to the seat, reporting both limbs separately. The deciding limb is the mechanical one: `references/contract-review.md:13` — 「**新导出符号或已发布载荷上的新键恒 `yes`**」. This diff adds **three** new exported symbols to `@objectstack/lint`'s published barrel (`validateRetiredPermissionResidue`, the `RetiredPermissionResidueFinding` type, and `PERMISSION_RETIRED_LIFECYCLE_RESIDUE`), verified by the seat from the diff against merge base `5741ff10c30`, and measured by the round in the built tarball (6 published dist files each). ⇒ `yes`, unconditionally. The round's own reasoning — no schema touched, no accept set moved, `packages/spec` not in the diff — is the OTHER limb and is accurate; it does not reach this one. `needs:contract-review` hung on both carriers (PR and card) in the same stroke. This is the **second, lint half** of the card. The parse half landed as objectstack-ai#17485 and is not re-opened here: objectstack-ai#12840's retired-default residue tolerance stays exactly as ruled, `packages/spec/src/security/permission.zod.ts` and `shared/retired-key.ts` are untouched, and nothing about what parses changes. This implements the director seat's **ruling D** — the missing signal is delivered where the authored path and the built path ARE distinguishable, which is before the parse. ## The gap, in the contract's own words `acceptRetiredDefaultResidue` states why its accept is silent, and in the same sentence names the channels that stay loud for authored sources: > the strip is deliberately SILENT — real artifacts carry the residue once per permission entry, and a per-occurrence notice would be a 75-line storm that teaches operators to skim; the loud channels for authored sources (tsc `never`, `os migrate meta`, the D2 conversion) are unchanged. Read that list against a non-TypeScript author and it is one entry short. * `tsc never` is a TypeScript channel. An author using `definePermissionSet` cannot write the key at all. * `os migrate meta` and the ADR-0087 D2 conversion are the same channel twice — and that conversion, `permission-allow-restore-purge-removed`, is declared `retiredFromLoadPath: true`, so it never runs while a stack loads. **Measured**: `normalizeStackInput` over a raw stack carrying `allowRestore: false` emits **0** conversion notices and hands the key straight through. So an author who writes the key in a JSON or YAML source and does not run the migration gets a clean parse and no signal at all — which is what a tombstone exists to prevent, and it is exactly the complaint the card was filed for. ## Population measurement — taken FIRST, because it gates the severity The ruling made this the ordering, so it is reported before the choice it gates. **Authored stack sources in this tree carrying the retired keys: ZERO.** The census classified every in-tree carrier structurally rather than by token count (occurrences via `grep -o`, never `grep -c` line counts): | class | occurrences | largest carrier | |:--|--:|:--| | built artifact / fixture | 150 | `packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json` (75 + 75) | | tests | 123 | `packages/spec/src/security/permission.test.ts` (44) | | spec / runtime machinery that NAMES the keys | 88 | `packages/spec/src/security/permission.zod.ts` (26) | | docs and changelog prose | 99 | `packages/spec/CHANGELOG.md` (18) | | changesets | 4 | — | | **authored stack source** | **0** | **none** | **LIT CONTROL — the census could have found one.** The two real authored permission sets in this tree (`examples/app-showcase/src/security/permission-sets.ts`, `examples/app-crm/src/security/sales-positions.ts`) carry **99** and **28** occurrences of live object-permission keys (`allowRead` / `allowCreate` / `allowEdit` / `allowDelete` / `allowTransfer`) in exactly the `objects: { NAME: { ... } }` shape this rule reads. The probe is aimed at files that really do carry object-permission blocks, and it returns a positive number on them — so the zero for the retired keys is an absence, not a miss. **DARK CONTROL**: a fabricated `allowTeleport` returns 0 in the same files, same expression. There is also a structural reason the zero is not surprising, and it is worth stating because it bounds the rule's reach: every tracked `objectstack.config.*` in this repo declares its metadata in TypeScript code, and `objectstack.json` in this tree is the **built artifact** (`dist/objectstack.json`), not an author's source. The ruling's own warning — that the 181 carriers are fixtures and built artifacts, not sources — holds, and the in-tree source population beneath it is empty. ### Severity: `warning`, and the measurement is what supports it 1. **A zero population is not an evidence base for a gate.** There is no measured false-positive budget to spend and no in-tree carrier to prove the rule would refuse the right thing. `error` would be a refusal grade chosen on zero observations. 2. **`error` would reverse ruling D by the back door.** The parse ACCEPTS `allowRestore: false`. An `error` at the authoring door makes `os build` refuse a stack the schema accepts — which is option B's accept-set narrowing, restricted to the CLI, and both objectstack-ai#12840 and ruling D declined it. `warning` is the only grade that adds a signal without moving a gate. 3. **The registry's own tier rule agrees.** `gating` means the rule can emit `error` and therefore must run on all three commands as a publish gate; `advisory` never emits `error`. This is advisory, and `authoring-rule-wiring.test.ts` reads the rule's own source to keep that claim honest. Ruling D named `warning` as its expectation and conditioned the final choice on the measurement. The measurement supports it, so `warning` it is. **And the honest reading of what a zero population means for D itself**: today this rule would fire on nothing in this repository. Its reach is authored JSON/YAML sources outside the tree — and the ruling already names the condition under which B re-opens as a new decision card, "AI-generated JSON that never runs lint". A lint rule cannot reach an author who never runs lint. That limit is not closed by this PR and is not claimed to be. ## What the rule does One rule, `validateRetiredPermissionResidue`, in `packages/lint/src/validate-retired-permission-residue.ts`. * **Reads raw source**, `input: 'normalized'` — the `normalizeStackInput` output, before any Zod parse. That tier is load-bearing rather than conventional here: the evidence is a key the residue stage removes, so a `parsed` rule would read a stack that structurally can never carry it. * **Fires on the captured residue value and nothing else.** `true`, `'false'`, `0` and `null` already land on the tombstone's own refusal with the prescription attached; repeating them here would be a second voice one layer earlier. The surviving enforced lifecycle bit, `allowTransfer: false`, is not residue and is never named. * **Carries the prescription, read rather than retyped.** `retiredKey()` publishes its guidance as the key's own description; the hint is resolved from `ObjectPermissionSchema`'s shape at call time, so it cannot drift from the parse-time wording the same author sees through the other door. An unresolvable prescription yields no finding rather than a wording this module invented — the posture `lintLivenessProperties` takes to an unreadable ledger, which is why the test carries an anti-vacuity guard. The finding splits the ruling's "message = the retired-key prescription" across the two fields the shared `AuthoringFinding` shape already has: `message` says what is wrong (the line is inert and silently stripped), `hint` is the prescription verbatim. Every other rule in the registry uses the same split, and the prescription reaches the author either way. ### Registration, and which commands run it Appended to `AUTHORING_RULES` in `packages/lint/src/authoring-rules.ts` — the existing table, no new mechanism. That one entry reaches **`os validate`, `os build` and `os lint`** (`commands: ALL`), which is also `os compile`'s gate, since `compile.ts` makes the same `runAuthoringRules('build', ...)` call. `surfaces: CLI_ONLY` with a written `surfaceReason`: crossing to the runtime publish gate needs a measurement this round did not take — whether that gate's `body` reaches it BEFORE the per-type `safeParse` whose residue stage strips the only evidence this rule reads. Post-parse the rule is structurally silent, so wiring it there without that reading would publish a phantom check rather than coverage. The rule id constant is re-exported from `src/index.ts`, per `rule-id-barrel-exports.test.ts`. ## Controls and ablation The test carries paired controls throughout (`packages/lint/src/validate-retired-permission-residue.test.ts`, 17 cases): * **LIT** — the residue survives `normalizeStackInput`; the rule fires once per key with the right path and severity; it reaches an author through `runAuthoringRules` on all three commands, with the parsed tier deliberately handed a CLEAN stack so a fallback to `parsed` would be visible. * **DARK** — a clean permission set, a fabricated key, and every non-residue value earn nothing; the same runner is silent on a clean stack; malformed input never throws. * **COST DIRECTION** — `allowTransfer: false`, the surviving ENFORCED lifecycle bit, is the nearest miss in the shape (same family, same object, same `false`) and must never be named; flagging it would tell an author to delete a live grant. **Ablation**, both legs proven on disk by occurrence count AND `git hash-object` before the run, restored against the HEAD blob after it, with a `trap` on absolute paths: | leg | mutation | verdict | |:--|:--|:--| | guard removed | the residue detection short-circuits | vitest **exit 1** — 3 failed / 14 passed, the LIT cases | | **cost direction** | widened to also match `allowTransfer` | vitest **exit 1** — 1 failed, exactly the COST DIRECTION case | Both legs restored: `git diff HEAD` empty and `hash-object` equal to the HEAD blob, checked rather than inferred from an exit code. ## Tests and gates | run | exit | |:--|--:| | `pnpm --filter @objectstack/lint build` + `pnpm --filter @objectstack/lint test` (lock VERDICT command-exit) | **0** — 102 files, 3766 tests, 0 skipped | | `pnpm --filter @objectstack/lint typecheck` (lock VERDICT command-exit) | **0** | | `eslint . --no-inline-config` over the WHOLE repo population | **0** — 6685 files, 0 errors, 0 warnings | | 88 derived gate families, run individually | 85 **exit 0**, 3 NOT MEASURED | The three NOT MEASURED are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`, each exiting **3** on its own PREREQUISITE NOT MET (they read built output the whole workspace has not produced here). A fourth, `check:skill-examples`, exited 1 with its own "Build first, then re-run" prerequisite text naming an unbuilt `@objectstack/client-react` whose build fails on its own unbuilt closure — a wrong-reason red, recorded as NOT MEASURED, not as red. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran` accounts for all 88 with 0 UNRUN. The eslint reading above is not a narrowing: the full population ran. No red anywhere. Two gates went red on the first sweep and both are fixed in the second commit — they are the mechanical consequences of the change, not incidental cleanups. `check:doc-authoring` refuses an internal tracker id inside customer-facing string prose, so the reference moved from the `surfaceReason` string to the adjacent comment. `check:docs-transcript-drift` derives the author-time rule count from the registry and compares it against the transcripts the docs quote: the new entry moves it 44 to 45, so the four pages printing it are refreshed. ## Changeset — measured, with `dist` BUILT `@objectstack/lint` publishes `dist` only. `dist` was unbuilt at first reading (a real `npm pack --dry-run` returned 3 files: CHANGELOG, README, package.json), so it was built and the measurement retaken rather than argued from the declared tsup entries. **With `dist` built**, `npm pack --dry-run` returns **17 files, 14 of them under `dist/`**, and all three new symbols are in the tarball: * `validateRetiredPermissionResidue` and `PERMISSION_RETIRED_LIFECYCLE_RESIDUE` — 6 published files each, including `dist/index.d.ts` and `dist/index.d.cts` * `RetiredPermissionResidueFinding` — the 2 declaration files * **lit control**: an already-published symbol, `lintLivenessProperties`, reaches 6 files. **dark control**: a fabricated symbol reaches 0. Published surface moves, so a changeset is owed and present: `.changeset/17425-retired-permission-residue-lint.md`, graded `minor` (additive; nothing is removed and no existing finding changes shape or severity). ## Declared overlap Sibling card objectstack-ai#17319's round has an open PR (objectstack-ai#17912, awaiting review) that also adds a rule under `packages/lint/src/` and edits the `src/index.ts` barrel. Declared rather than avoided, per this lane's ruled discipline: whoever lands second resolves. The barrel is an export list — on a conflict, merge main and re-add the export block. Also declared: the **file face grew past the claim's list**. The claim declared `packages/lint/` (rule, test, barrel). The diff additionally carries `.changeset/17425-retired-permission-residue-lint.md` and four `content/docs/` pages, the latter because the derived rule count they quote moved. Amending the claim comment is the seat's act, not this round's. Authored by Claude Code in session `session_01MkQhmuuJAVDjmeWNixwDDH`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <[email protected]>
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