Skip to content

feat(spec,lint): let an action declare its bulk dispatch contract, and refuse a view that wires it the other way - #17912

Merged
os-bill merged 5 commits into
mainfrom
claude/issue-17319-action-dispatch-contract
Sep 13, 2026
Merged

feat(spec,lint): let an action declare its bulk dispatch contract, and refuse a view that wires it the other way#17912
os-bill merged 5 commits into
mainfrom
claude/issue-17319-action-dispatch-contract

Conversation

@os-bill

@os-bill os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17319

Clause-②: yes — a new authorable key (execution) on a published schema (ActionSchema). needs:contract-review is 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

bulkActionDefs already names the two dispatches. Read from packages/spec/src/ui/bulk-action.zod.ts:

/** How many dispatches a `custom` def makes for a selection of N records. */
export const BulkActionExecutionSchema = z.enum(['perRecord', 'aggregate']);

⇒ the action's declaration is the same key execution with the same two values 'perRecord' | 'aggregate', and it is that same schema object, imported rather than re-declared:

execution: BulkActionExecutionSchema.optional()

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, bulkDispatch all rename onto execution) — the snake_case VALUE is refused. ⛔ mode is deliberately NOT aliased, the one place the two surfaces' alias tables differ: a bulk def aliases mode onto execution, but on an action mode is 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 PropertyAssignment named bulkActions whose value is an array literal contributes one per-record wiring per string element; one named bulkActionDefs contributes one entry per object literal, classified by that entry's own execution. 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 spelling bulkActions: and a commented-out wiring. The probe returned per-record 2 / aggregate 2 / one action classified wired-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.

corpus bare-string fan-out wirings aggregate def wirings distinct actions wired fan-out aggregate wired both ways
this repo — shipped app metadata (examples/, at a9c64779046) 22 2 13 11 1 1
objectstack-ai/hotcrm at c716a2ccb3d31574a1a238a590f3e331ddae0200 (pins @objectstack/* 17.4.0) 2 1 3 2 1 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 and packages/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:

  • aggregate, this repo: showcase_recalc_selection.
  • wired both ways, this repo: showcase_zoo_visible_stringlist.bulkActions and listViews.predicate_columns.bulkActions (per-record) plus listViews.inline_bulk_defs.bulkActionDefs (aggregate). Its body reads ctx.recordId AND input._selectedIds and 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".
  • hotcrm: add_contact_to_campaign and create_campaign fan-out, mass_update_stage aggregate — 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.

⚠️ It is a semantic entry and NOT a D2 conversion, deliberately. A conversion would be the mechanical way to write the key in, and it is the wrong instrument twice over: (a) the evidence lives on the VIEWS while the declaration belongs to the ACTION, so no per-item transform has both halves; (b) retiredFromLoadPath: true does not hold a transform to os migrate meta — the artifact-ingestion door replays the whole chain with includeRetired: true, which is exactly how the withdrawn field-required-notnull-explicit conversion handed every scaffolded app constraints it never asked for (the ⛔ WITHDRAWN block in packages/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/lint gains validateActionDispatchContract / action-dispatch-contract-mismatch (severity error), wired as a reference-integrity suite member so it runs on os validate, os lint and os compile at once, over every list tier (a view's list, each listViews.KEY entry, and an object's own listViews). Real message, for the showcase's own pair:

Action "recalc_selection" declares execution: 'aggregate' (a bulkActionDefs entry naming the action) — ONE dispatch for the whole selection, carrying every selected id in params._selectedIds and NO recordId, but view "task" · list wires it as execution: 'perRecord' (the view's bare-string bulkActions form — the literal message spells the placeholder with angle brackets, not reproduced here) — the renderer promotes the action to a def and dispatches it ONCE PER selected row, each call carrying that row's recordId and NO _selectedIds. The two contracts deliver opposite input to the same body: a body written for the aggregate call reads _selectedIds as undefined on every per-row dispatch, falls through to its single-record branch, and reports success for one row out of however many were selected. Nothing refuses this at runtime — recordId and _selectedIds are both builtin action params (ADR-0104), so the strict params gate admits either bag without a word.

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

execution is 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 _selectedIds is a builtin. Verified, not assumed: validateActionParams builds its allow-set from ACTION_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.ts reproduces 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 refused unknown_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 AND git hash-object before its result was read. HEAD blob of the rule file: 04113f029fa2fd511271c514ffd116683b2b96ed.

leg mutation landed (occurrences · on-disk hash) vitest tests
baseline none hash == HEAD blob, git diff HEAD empty 0 14 passed
A — the refusal has teeth declared === wiredtrue (rule never fires) injected 1 · anchor 0 · 9881ddd6… ≠ HEAD blob 1 6 failed, 8 passed
A restore git checkout HEAD -- 04113f02… == HEAD blob, diff empty
B — cost direction drop the matched-wiring early return (a CORRECT wiring is refused too) injected 1 · anchor 0 · c9bee82c… ≠ HEAD blob 1 3 failed, 11 passed
B restore git checkout HEAD -- 04113f02… == HEAD blob, diff empty
final restored 0 14 passed

Leg 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 bare git checkout --, which would take the mutation back out of the index), under a trap … EXIT INT TERM with absolute paths; the working tree is clean.

Repair round — packages/metadata-protocol enters this PR's file surface

A 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/types serves for action. This PR adds exactly one authorable key to ActionSchema (execution), so the served count moves 47 to 48 and the pin fails. The red Test Core (2/6) shard is this PR's own doing, reproduced locally before any edit:

result
before the edit exit 1AssertionError: expected 48 to be 47 at line 150; 1 failed / 17 passed (18)
after the edit, post-merge exit 018 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 blanket io: '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-protocol ships files: ["dist","README.md","CHANGELOG.md"] and @objectstack/spec ships dist plus src/**/*.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 (BulkActionDefSchema 26, getMetaTypes 4); no *test* file exists anywhere under either dist. examples/app-showcase is private: true. Nothing published moves, so Check Changeset staying 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:

package exit result
@objectstack/plugin-approvals 0 44 files, 733 tests passed
@objectstack/downstream-contract 0 3 files, 31 tests passed

@objectstack/downstream-contract needs @objectstack/cli built 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

run exit
metadata-protocol full suite 0 — 179 files (176 passed / 3 skipped), 2550 tests (2531 passed / 19 skipped)
spec project local (post-merge) 0 — 475 files, 13494 tests passed
spec project repo (post-merge) 0 — 31 files, 523 tests passed
lint suite (post-merge) 0 — 102 files, 3763 tests passed
spec typecheck (incl. check:test-typecheck) 0 — and tsconfig.test.json --listFiles does reach src/ui/bulk-action.test.ts, so this covers the new test rather than merely scheduling it
metadata-protocol typecheck 0--listFiles reaches the edited test file
example-showcase typecheck 0 — first read exit 2 with 21 TS2307, 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 0
eslint . --no-inline-config — whole population, not a narrowing 0 — 6687 files, 0 errors, 0 warnings
check: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-bounds 0 each, re-run after the origin/main merge

R4 ablation — the new pin can actually fail. Deleting mode: 'execution', from BulkActionDefSchema's alias table: file blob 17f99d36 to aaa4390e (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 to 17f99d36, anchor 1, git diff HEAD empty, suite exit 0 with 29 passed (29). Before this round the file had 28 tests and deleting that line reddened nothing.

origin/main was merged once as a merge commit (never a rebase); it brought 15 files, none under packages/spec, packages/lint, packages/metadata-protocol or examples/app-showcase, and left no os-regen debt.

Verification

run exit
pnpm --filter @objectstack/spec test (project local) 0 — 475 files, 13493 tests passed
pnpm --filter @objectstack/spec test:repo (project repo) 0 — 31 files, 523 tests passed
pnpm --filter @objectstack/lint test 0 — 102 files, 3758 passed / 5 skipped
pnpm --filter @objectstack/spec typecheck 0
pnpm --filter @objectstack/lint typecheck 0
pnpm --filter '@objectstack/lint^...' build (dependency closure) 0
pnpm exec eslint . --no-inline-config — the WHOLE population, not a narrowing 0 — 6687 files, 0 errors, 0 warnings
spec artifact gates: check:authorable-surface · check:docs · check:api-surface · check:migration-registry · check:spec-changes · check:upgrade-guide · check:liveness · check:generated 0 each
check: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-casing 0 each
check:published-readme-exports exit 3 — NOT MEASURED, and reported as such: it needs every package built, which is CI's lap. ⛔ Not read as green.

Artifacts regenerated, never hand-edited: authorable-surface/ui.json, liveness/state-counts.md, src/migrations/registry.ts (via gen:migration-registry), content/docs/references/**.

Changeset — measured, not assumed

.changeset/17319-action-bulk-dispatch-contract.md, @objectstack/spec minor + @objectstack/lint minor (both additive: a new optional authorable key, a new rule). Measured against a real npm pack --dry-run --json for @objectstack/spec: 2012 entries, of which src/ui/action.zod.ts, liveness/action.json, liveness/state-counts.md and 216 dist/ entries — so an edited file this PR touches ships literally, not only compiled. Controls in the same reading: src/ui/action-dispatch-contract.test.ts and .changeset/** are ABSENT from the pack, so the presence readings are a measurement and not a listing of the whole tree. @objectstack/lint publishes dist only and its tsup entry list is ['src/index.ts', 'src/runtime.ts']src/index.ts is edited here, so its published surface moves too (argued from the declared entry, since packages/lint/dist is not built in this checkout; ⛔ not claimed as a pack measurement).

验收备注

  • The rule stops at the mismatch, by design. An action that declares nothing gets no finding, not even a warning. Refusing or warning on every undeclared bulk-wired action would fire on every app that predates this key, and the migration — not the linter — is the declared path for those. Worth revisiting once the migration has run across the corpus.
  • The runtime does not refuse a mismatched dispatch, because the ruling ruled the lint half (item 2). The card's own proposal 3 ("the runtime can refuse the dispatch itself") is a separate card and would be a second reader of this key; the liveness row says so rather than implying the key is runtime-enforced.
  • noted, not filed: showcase_mark_done's body throws 'No record to mark done' when it has no recordId — literally the card's second misfire direction, in our own reference app. It is now declared perRecord, so the mistake is a build-time refusal; nothing to file. 承接者: the next card to touch examples/app-showcase/src/ui/actions/index.ts.
  • noted, not filed: the nine showcase_zoo_perm_* / showcase_zoo_*_gate actions are left undeclared. They are fan-out by wiring and the migration would derive perRecord for 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

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/lint, @objectstack/metadata-protocol, @objectstack/spec, touching 19 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/metadata-protocol/src/protocol.ts, packages/spec/authorable-surface/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/ai/agents.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))
  • content/docs/api/client-sdk.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))
  • content/docs/api/error-catalog.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))
  • content/docs/data-modeling/index.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))
  • content/docs/deployment/validating-metadata.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))
  • content/docs/permissions/authorization.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))
  • content/docs/ui/index.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))
  • content/docs/ui/views.mdx (via perRecord (symbol, a field of const object CONTRACT_PROSE; a field of const object MISFIRE), perRecord (literal, a string literal in ActionDispatchContract; a string literal in CONTRACTS; a string literal in acceptanceCriteria; a string literal in actionObject; a string literal in perRecord; a string literal in replacement; a string literal in semantic; a string literal in validateActionDispatchContract))

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

  • content/docs/releases/v13.mdx (via crm_lead (literal, a string literal in ActionDispatchContractFinding))

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

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/metadata-protocol/src/protocol.ts, packages/spec/authorable-surface/ui.json, …) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7135cf057e90dbe6d9177aef17e525da7277f68bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a38ddd573a4719cc8766acec03ad27b85ae11c84 — the merge of head 3f04d9c4c1a96a26ff529a9a5bdc8ac4ddd7aa02 into base 7135cf057e90dbe6d9177aef17e525da7277f68b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7135cf057e90dbe6d9177aef17e525da7277f68b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

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 ActionSchema carries a hard property-count pin that this diff moves. Test Core is RED on head 8a79cee8a0a, and it is one of the six required contexts.

Read against origin/main and the PR head by ref, in a dedicated worktree at 8a79cee8a0ac174f6e43139b0c46647e3a28480a; merge-base a9c64779046facc1b0b2e74f71a9441b0f5b7fb8. Verification runs held scripts/pm/os-verify-lock.sh on a stable slot.


REVISE list

R1 — BLOCKER, required context. Two edits in one file. packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts:150 pins Object.keys(properties).length for the served action schema to 47. Adding execution to ActionSchema makes it 48. Reproduced locally, not inferred from CI:

FAIL src/protocol.meta-types-degenerate-derivation.test.ts
  > #17501 — /meta/types serves a real schema for `action`, and moves nothing else
  > serves `action` with a populated schema, not the empty husk
AssertionError: expected 48 to be 47 // Object.is equality
Test Files  1 failed (1)     Tests  1 failed | 17 passed (18)

Same assertion, same file, same line as CI job Test Core (2/6).

  • :150toBe(47) becomes toBe(48).
  • :4-5 — the module docblock too. It opens "must not serve an empty JSON Schema for a type that accepts 47 keys". Move one and not the other and the file keeps a confidently wrong sentence at the top, which the next reader will trust over the assertion.

Consequence the round must declare: the PR's file surface grows into packages/metadata-protocol, which its claim and its Verification table do not name. No additional changeset entry looks owed — the edit is test-only, nothing published moves, and Check Changeset is already green — but confirm that rather than inherit it from me; it is one command.

R2 — re-measure what the failing shard never reached. check-test-completeness on that job reports @objectstack/plugin-approvals and @objectstack/downstream-contract "scheduled but never reached — the run stopped before it." Those are NOT MEASURED, not green. After R1 lands, confirm both actually report. (I tried downstream-contract in my worktree and it refused with a prerequisite error — @objectstack/cli is not built — which is a refusal, not a result, so I have no reading on it either.) This is the only unmeasured surface left after the sweep below.

R3 — minor, one edit. The inserted JSDoc lines in examples/app-showcase/src/ui/views/field-zoo.view.ts:156-164 sit at 1-space indent inside a block indented 5. Confirmed on disk with cat -A, not from the diff. ESLint does not judge comment indentation, so the green eslint run is not evidence against this. It is the reference app's own source.

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 47 a movable count, or an invariant that refuses a widening?

It is a movable witness. 47 → 48 is the correct repair, and the file says so itself.

Read whole, the suite separates the two jobs deliberately, and the docblock is explicit about which one is load-bearing:

"A suite that only pinned action's 47 keys would stay green through a later widening to io: 'input' for everything — which is the change this card exists to refuse. This one goes red on it."

The anti-widening invariant is therefore not the count. It is the blast-radius assertion at :157-173expect(moved).toEqual(['action']) — and its baseline is recomputed live, which the docblock states is the point: "⚠️ The pre-fix baseline is recomputed here from the raw output derivation rather than checked in, so the assertion keeps meaning as schemas evolve: it always asks 'how many types does the degeneracy retry move, TODAY'." A suite written that way is written to survive schema growth, not to refuse it.

Measured, not read off the intent: on this head the blast-radius pin passes. My local run is 1 failed | 17 passed (18) — the single failure is the count at :150. The two structural invariants (moves the served payload of EXACTLY one type, leaves every other type byte-identical) are both green, so this diff does not widen the derivation; it adds one key to one schema, which is exactly what the count is there to witness.

The file also carries its own lit control for the count instrument: CARD_PROPERTY_COUNTS at :203-206 pins thirteen OTHER types' property counts (agent 26, object 43, field 74, …), and action is deliberately not among them. Those thirteen are green. So the instrument works, it is aimed at the right thing, and it moved for exactly one type — the one this PR touches.

⇒ Not a CHANGES-REQUIRED of a different kind. It is a declared key on a reviewed surface moving a witness by one, plus the docblock that repeats the witness in prose.

R1 in depth — is 47 → 48 the whole repair list?

As far as anything measured reaches: yes. The residual is exactly R2. Three classes swept, each with a lit control, because a zero here is worthless without one.

Class 1 — type-level key-set pins (the automation-api.zod.test.ts TS2344 shape the round already hit). Not swept by grep: measured. On this head TypeScript Type Check is success, and so are Type Check · workspace, · consumer gates, · debt ledger and · source gates. That is a whole-workspace empirical reading over this entire class, far stronger than any pattern I could write. Its control is the round's own history: a TS2344 of exactly this shape went red earlier in this card's development, which proves the typecheck lane does go red on this class — so a green one here is a reading, not a dead probe.

Class 2 — runtime numeric count pins. Swept every *.test.ts under packages, apps and examples for a numeric toBe / toEqual / toHaveLength against a keys-length or properties-length expression. Control: the sweep must find the known instance, and it doesprotocol.meta-types-degenerate-derivation.test.ts:150 is in the output. Everything else it returns binds something unrelated: ISO-4217 fraction digits, scoped-context legal/rejected key sets, cache keyCount, driver table counts, and a hono objects/fields delta. No second action-bound count pin exists.

Class 3 — checked-in baselines that enumerate action keys. Three files carry ui/Action: rows: packages/spec/authorable-surface/ui.json (updated by this PR, now 48 Action rows — consistent with the served count), packages/spec/authorable-defaults/ui.json (2 Action rows) and packages/spec/authorable-surface.base.json (43 Action rows). Neither of the latter two moves here, and that is correct, proved by a control rather than asserted: ui/Action:patch, an optional key that demonstrably DID land on the moving baseline, is likewise absent from both of the other two. So a new optional key not appearing in authorable-defaults (it has no default) or in the frozen .base.json is those files' established behaviour, not an omission. Spec property liveness is green on the head, and Lint & Repo Gates — which carries every check:* gate, check:authorable-surface and check:generated included — has since completed success.

Where the six required contexts stand on 8a79cee8a0a: Lint & Repo Gates success · TypeScript Type Check success · Test Core FAILURE · Dogfood Regression Gate success · Build Core success · Temporal Conformance (live PG + MySQL) success. Exactly one red, and R1 is its whole content — subject only to R2, since two packages in the failing shard never ran at all.


Q1 — is "no third spelling" structural?

Yes, structurally — and the alias judgement is right, but the asymmetry is only half pinned.

  • One enum object, not two. packages/spec/src/ui/bulk-action.zod.ts:104 declares BulkActionExecutionSchema; packages/spec/src/ui/action.zod.ts:22 imports that symbol and :1190 is execution: BulkActionExecutionSchema.optional(). There is no second enum to drift from.
  • No cycle. bulk-action.zod.ts imports only zod, three ../shared/ modules and ../data/field.zod (its import block, lines 3-8). Nothing it reaches imports back into ui/: the only ../ui/ imports anywhere under packages/spec/src/shared/ are in three .test.ts files, which are not on the module graph the runtime import closes. Verified by grep over shared/, data/field.zod.ts and data/field-value.zod.ts.
  • One honest qualification on "propagates by construction". It does — for the schema. But packages/spec/src/ui/action-dispatch-contract.test.ts:79 pins BulkActionExecutionSchema.options to the two current members, so adding a member to the def propagates to ActionSchema and turns that pin red. That is correct change-detector behaviour, not a defect; it just means the right claim is "cannot drift silently", not "propagates silently".

The alias table judgement is right, and for a stronger reason than the comment gives. mode is a declared key on ActionSchema at action.zod.ts:1525, enum create | edit | delete | custom. Aliasing it would not merely eat a real declaration — it would rename mode: 'create' onto execution: 'create', which is not even a member of the contract enum, so a legitimate authoring line would be renamed into a validation error. The def does alias it (mode: 'execution', bulk-action.zod.ts:164), so the asymmetry the PR describes is real in code. The four aliases it does add (dispatch, dispatchContract, bulkExecution, bulkDispatch, at action.zod.ts:874-875) produce a refusal naming execution, not a silent acceptance — pinned at action-dispatch-contract.test.ts:120-126. That is the declared-and-loud form Prime Directive #12 requires, not a lenient shim.

But the asymmetry is pinned on one side. The action side — the load-bearing half — is pinned at action-dispatch-contract.test.ts:128-135. The def side is not: I grepped packages/spec/src/ui/bulk-action.test.ts and it never exercises the mode alias. Delete mode: 'execution' from bulk-action.zod.ts:164 today and no test reds, while the comment at action.zod.ts:869-871 ("the def aliases mode onto execution") silently becomes false. R4: add the one assertion that the def DOES rename mode, beside the one that says the action does not. That is what makes it a pinned asymmetry rather than a remembered one — which is the whole argument of this PR applied to itself.

Q2 — is the census sound?

Yes. Independently reproduced, to the number and to the names. The round's probe is not committed, so I wrote my own from the described semantics using the TypeScript parser, and I lit my own control before reading either corpus.

My control (one file): one plain bare-string wiring, one aggregate def, one action wired both ways, one data-plane operation: 'update' def, plus three negatives — a picklist option whose value is aggregate, a prose string naming the wiring key, and a commented-out wiring. It returned bare-string 2 / aggregate-def 2 / distinct 3 / fan-out-only 1 / aggregate-only 1 / both-ways 1 / data-plane 1 counted separately and not as an action wiring, and ignored all three negatives. Every bucket the census reports a number in comes back non-zero in the control, the both-ways bucket included — which is the bucket a zero is reported in. Aimed at: the two authored wiring structures (bulkActions array elements that are string literals; bulkActionDefs object-literal entries classified by their own operation/execution), which is what the migration acts on. It is not aimed at, and cannot see, a wiring assembled at runtime or spread in from a variable.

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/spec minor + @objectstack/lint minor. 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 Changeset is green on the head.
  • Generated artifacts are consistent with exactly one added key: authorable-surface/ui.json gains one row (ui/Action:execution); liveness/action.json gains one live row whose evidence names the single consumer and whose note states the deliberate non-enforcement at runtime; state-counts.md moves action live 43 to 44 and the total 861 to 862; migrations/registry.ts gains the semantic entry. Spec property liveness is green on the head.
  • Docs cover both surfaces, as item 4 requires: content/docs/ui/actions.mdx gains the declaration section and content/docs/ui/views.mdx gains the cross-linked counterpart on the wiring side. No content/docs/releases/ path appears in the diff — correct.
  • check:published-readme-exports exit 3 — the reading is right, and nothing else hides behind it. 3 is EXIT_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 in packages/lint/src/index.ts, and packages/lint/README.md names only validateWidgetBindings and validateStackExpressions and 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_done is now declared perRecord, 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 Gates completed success after 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-approvals and @objectstack/downstream-contract on this head: never reached in CI, and my own attempt at downstream-contract refused on a prerequisite (@objectstack/cli not 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-run measurement, the whole-population eslint run, and the full @objectstack/spec and @objectstack/lint suites — 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-grid page-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.

⚠️ And the second half of that sentence is not a tier reading. .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
…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
@os-bill
os-bill marked this pull request as ready for review September 13, 2026 05:34
@os-bill
os-bill added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 23fc5d6 Sep 13, 2026
40 checks passed
@os-bill
os-bill deleted the claude/issue-17319-action-dispatch-contract branch September 13, 2026 06:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

2 participants