Repository navigation
fix(spec): composeStacks carries each bound standalone action once in the composed object - #15022
Conversation
… the composed object `mergeActionsIntoObjects` is idempotent over its own output: a bound action the object already carries by identity is not appended again, so the second merge `composeStacks` runs over inputs built by `defineStack` no longer doubles every bound action in the composed object. Identity against the standalone list, not equality — a hand-written twin is two objects after the strict parse and stays refused by the same-key rule, which runs before the merge and is untouched. Card: issue 14847 (spec lane). Pins in compose-stacks-action-echo.test.ts; the app-multi-package mirror pin in the collision test tightened to the contract. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
📓 Docs Drift CheckThis PR changes 1 package(s): 2 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 — 128 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 41a7a209583ca5e4bac713bdb085e54f647ed503 && git checkout 41a7a209583ca5e4bac713bdb085e54f647ed503
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fddfc8db062d61ca68ba482531f5368326109554 f1ed87e8c84e5fd90836cbf904a44af06684ebf8 && git checkout -B drift-repro fddfc8db062d61ca68ba482531f5368326109554 && git merge --no-ff f1ed87e8c84e5fd90836cbf904a44af06684ebf8
node scripts/docs-audit/affected-docs.mjs --json fddfc8db062d61ca68ba482531f5368326109554
|
|
Landing provenance (PM seat
Generated by Claude Code |
…data/analytics.zod.ts to the commits that decided them (stage 7) (objectstack-ai#20616) Part of objectstack-ai#20234 Clause-②: no Stage 7 of the staged sweep: `packages/spec/src/stack.zod.ts` and `packages/spec/src/data/analytics.zod.ts`, both freed by landings (PR objectstack-ai#20579 and PR objectstack-ai#20458). Its claim is `5885635758`. Every comment or docblock line in those two files that cited a tracker number answering 404 now cites the commit on `main` that decided its rule, in ruling C+D's form C, and says in its own words what was decided. Comments only: 12 lines out, 12 in, across 2 files. No code token, string literal or `describe()` text moves. No dead site stays: none of the 12 is read by literal. The census is the gate's own `node scripts/check-issue-citations.mjs --census --json`, filtered to the two paths. Before: base `0f6dcac5e9`, board enumerated (185 pages, frontier objectstack-ai#20611). After: head `cc0580d404`, board enumerated (185 pages, frontier objectstack-ai#20615). ## Measurement | file (under `packages/spec/src/`) | dead before | after | numbers, then anchor | |---|---:|---:|---| | `stack.zod.ts` | 9 | 0 | objectstack-ai#10485 ×2 (`:415`, `:1023`) to `35ad101bc`; objectstack-ai#6238 (`:633`) to `c8d6f6e08`; objectstack-ai#14192 (`:1233`) to `4d0d9445a`; objectstack-ai#14686 ×2 (`:3037`, `:3194`) to `279431e7a`; objectstack-ai#14662 ×3 (`:4510`, `:5070`, `:5293`) to `35dffeace` | | `data/analytics.zod.ts` | 3 | 0 | objectstack-ai#10194 ×3 (`:404`, `:407`, `:485`) to `2306a765c` | | **2 files** | **12** | **0** | 6 numbers removed, 6 distinct shas | Per-file counts at base equal the claim's (9 and 3, from stage 6's census). A second instrument agrees site for site: every `#N` in the two files, classified by the TypeScript parser, and each of the 84 distinct numbers of 100 or more probed by REST `issues/N` without following redirects (the other 3 are the ordinals `Prime Directive objectstack-ai#12`, `batch objectstack-ai#23`, `batch objectstack-ai#57`). - Base: 244 sites, all in comments (0 strings, 0 code). 78 numbers answer 200 and 6 answer 404: the same 6 numbers and the same 12 sites as the gate. - Its string-class positive control found 11 string sites in `kernel/manifest-unknown-keys.test.ts` and `packages/cli/src/utils/lower-callables.test.ts`. - Head: 232 sites, 78 numbers, all 78 answer 200 (the same 78), none answers 404. - Lit controls objectstack-ai#16862, objectstack-ai#16847 and objectstack-ai#17698 answered 200 at every checkpoint (3 at base, 3 at head); dead controls objectstack-ai#16714, objectstack-ai#16715 and objectstack-ai#16697 answered 404 at every checkpoint. ## Why each anchor decides its line Each sha resolves uniquely, is an ancestor of `origin/main` (and of the base), and has one parent. No file under `docs/adr/**`, `docs/NORTH-STAR.md` or `scripts/adr-anchors/` names any of the six numbers or records these rules, so each takes the commit rung, as stages 1–6 did. - **objectstack-ai#10485 to `35ad101bc`** (`:415`, `:1023`): retires the `themes` carrier key and `ThemeSchema` under ADR-0049. Its message records the ruling, "Ruled B (退役授权面, 2026-08-21)", and its own `stack.zod.ts` diff wrote both lines. `:415` keeps ADR-0049 and the ruling in its words; the D3 entry `stack-themes-carrier-retired` it names on `:423` is unchanged. This is the anchor stages 1, 5 and 6 used for the same retirement. - **objectstack-ai#6238 to `c8d6f6e08`** (`:633`): widens the array member of `functions` so its `handler` also takes the lowered string ref, which is the fix for `objectstack build` refusing its own array output. Its message names objectstack-ai#6238, and its own diff wrote the line. objectstack-ai#4343 and objectstack-ai#4976 on the same line stay (both 200). - **objectstack-ai#14192 to `4d0d9445a`** (`:1233`): turns `ManifestSchema` and its nested blocks into `strictObject` and flips the assembled-body strip pin to a refusal pin; each of its sub-commits names objectstack-ai#14192. The line itself was written later by `c78c9180de`, whose own message says "objectstack-ai#14192 closed ManifestSchema with strictObject", so the commit that closed it is the anchor. - **objectstack-ai#14686 to `279431e7a`** (`:3037`, `:3194`): "defineStack refuses two actions that resolve to one scope-qualified runtime key". Its subject names objectstack-ai#14686, and its diff adds `collectDuplicateActionKeyErrors` and the changeset for that refusal. Both lines were written later by `773a99960a` (PR objectstack-ai#15022), whose message describes the same "same-key rule, which runs before the merge". - **objectstack-ai#14662 to `35dffeace`** (`:4510`, `:5070`, `:5293`): "composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key". It checks the composed set with the rule `defineStack` applies within one stack, with no `actionConflict` option (maintainer ruling 2026-09-03). Its message does not name objectstack-ai#14662; its own `stack.zod.ts` diff wrote all three `(objectstack-ai#14662)` lines. - **objectstack-ai#10194 to `2306a765c`** (`analytics.zod.ts:404`, `:407`, `:485`): binds `analytics_cube` (and `theme`) in `UNREGISTERED_KIND_SCHEMAS`, so `PUT /meta/analytics_cube/:name` parses through `CubeSchema`, and gives `CubeSchema` the `...MetadataProtectionFields` spread. Its message names objectstack-ai#10194, and its own diff wrote all three lines. The `[objectstack-ai#10194]` markers become `[commit 2306a76]`, the spelling stages 1 and 5 already use in `kernel/metadata-type-schemas.ts`. ## Mechanical proof - **Token guard** (my `tokcmp.mjs`: TypeScript 6.0.3 leaf tokens, JSDoc kinds excluded, controls mutate the head text in memory only). Base `0f6dcac5e9` against the head, 2 files, 17,249 base tokens: - Real run: 0 files with a token change (exit 0). - Comment-insertion control (`data/analytics.zod.ts`): 0 (exit 0). - Code-insertion positive control (`stack.zod.ts`, a declaration appended): DIFFER at token 15388 (exit 1). - String positive control (the first `StringLiteral` the parser locates in each file): DIFFER at token 5 (exit 1), once per file. - `describe()` positive control (the first `.describe()` string argument the parser locates: `stack.zod.ts:133`, `analytics.zod.ts:244`): DIFFER at tokens 507 and 442 (exit 1). - **Line balance**: `stack.zod.ts` +9/−9, `data/analytics.zod.ts` +3/−3; line counts equal at base and head (5344 and 853). - **Tracker numbers**: added-not-removed is empty in both files, and no `PR #N` is on an added line. Net-removed: 12 sites, 6 numbers. The only numbers on added lines are objectstack-ai#4343 and objectstack-ai#4976, which stay on `:633`. - **Shas**: 6 distinct on added lines, 0 on removed lines. - `rev-parse --disambiguate` answers 1 object for each. - `merge-base --is-ancestor` exits 0 for each, against `origin/main` `7510663c87` and against the base; each is single-parent; the repository is not shallow. - **Literal readers**: all 26 string, template and regex literals in the repository that carry one of the six numbers (42 code files) were matched against the two files' base text: 0 occur there. Each removed line was also cut into 4-word windows (96) and searched across the tree: the 9 hits inside string literals are other files' own test titles sharing a phrase ("the ADR-0010 protection envelope", "an assembled body is"), and none reads either file. The source-text readers of the two files read code, not these comments: `compose-stacks-refusal-envelopes.test.ts` counts `throw new Error(`, and `check-stack-collection-maps.mjs` and `check-skill-top-level-keys.mjs` read the declared collections and keys. ## Tests and gates (at head `cc0580d404`) - `pnpm exec turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*` under `os-verify-lock`: Tasks 71 successful, 71 total, VERDICT command-exit 0. - `pnpm --filter @objectstack/spec check:generated` under the lock: all 15 generated artifacts up to date, `check:docs` over `content/docs/references/**` included; VERDICT command-exit 0. No reference page projects any of the 12 lines, so none is regenerated. - `vitest run --maxWorkers=2` under the lock over the two files' own suites (`src/stack*`, `src/compose-stacks*`, `src/define-stack*`, `src/assembled-package-body`, `src/data/analytics*`, `src/data/cube*`): Test Files 35 passed (35), Tests 976 passed (976). - The 37 spec suites that read source text across `src/`, or carry one of these numbers, under the lock: Test Files 37 passed (37), Tests 759 passed (759). - `scripts/{category-title,dist-freshness,dist-freshness-adoption,file-description,strictness-ledger,strictness-ledger-doc,root-index,skill-map-guards,export-origins,split-entries,root-entry-type-nameability.pin}`, `scripts/liveness/{evidence,tombstoned-row-status}`; - `src/type-alias-convention.pin`, `src/eager-entry-import`, `src/api/{api-entry-graph.pin,auth,export-job-family-retirement}`, `src/ai/tool-confirmation-prescription-tense.pin`, `src/data/{currency-mode-family-closure.pin,external-lookup-retirement}`, `src/identity/position-delegatable-enforcer.pin`, `src/integration/{connector-connection-timeout-retirement,connector-resilience-keys-retirement}`, `src/security/rls-tags-retirement`, `src/shared/{alias-integrity,retired-key-migrate-sentence}`, `src/system/{compliance-families-retirement,constants/platform-object-names,email-template-floor-locale-parity.pin,message-queue-retirement}`, `src/ui/{action-requires-confirmation-docblock.pin,i18n,interaction-config-retirement,strictness-batch14}`, `src/kernel/{manifest-unknown-keys,metadata-type-schemas}`. - Left to CI: `scripts/{build-schemas-check-mode,def-key-collisions,openapi-self-consistency}` (each rebuilds artifacts in a temp tree) and `scripts/{check-generated-ledger,check-generated-fix-rebuild.pin}` (read the ledger and `dist`). None reads comment text. - `pnpm --filter @objectstack/spec typecheck` under the lock: exit 0; `check:test-typecheck` OK (53 files / 251 errors / 138 pinned signatures held). - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 2 files gives 2 files, 0 errors, 0 warnings. - `isPathIgnored` is false for both, read through eslint's API. - `eslint.config.mjs:327-328` says type-aware linting is never enabled, so a comment edit cannot move an untouched file's verdict. - The repo-wide `pnpm lint` is CI's. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 79 families derived and run, every one exit 0. `--ran` reads "79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN". Among them: - `pnpm check:issue-citations` (self-test, 114 cases in 8 batteries) and the live diff-scoped `node scripts/check-issue-citations.mjs`: it judged the 2 citations on added lines, objectstack-ai#4343 and objectstack-ai#4976, and both are live issues. - `pnpm check:doc-authoring`: 16,804 customer-facing strings across 1,179 spec sources clean; the sibling baseline holds. - `pnpm check:stack-collection-maps`: 8 enumerations reconciled against 31 declared collections. - Changeset: `patch` for `@objectstack/spec`. Both files are `src/**/*.zod.ts`, which `files[]` ships verbatim, and the rewritten docblocks reach `dist`: "posture: commit 4d0d944 closed" and "[commit 2306a76] This docblock used to say" are each in 2 `.d.ts`, their old spellings in 0. Positive control: the unchanged neighbouring sentence "BY INHERITANCE — an undeclared key on one is REFUSED" is in the same 2 `.d.ts`. - Merge probe: a no-driver `merge-tree` of the head onto `origin/main` `7510663c87`, from a bare shared clone, exits 0. The 3 commits `main` gained since the base touch neither file nor the citation or derivation scripts, and a re-derivation prints the same 79 commands. No merge was made. - No ablation or reverse verification: the change is comment-only, so there is no behaviour to invert. ## Hypotheses (measured first) 1. **Holds.** 12 dead sites at the tip, 9 in `stack.zod.ts` and 3 in `data/analytics.zod.ts`, equal per file to stage 6's census. 2. **Holds.** Read at 2026-09-29T07:36Z and again at 08:16Z, after the last push and before this PR was opened: all open PRs' full file lists (9 PRs, 166 files at the second read) and the newest `Claim:` on all 11 `pm:dispatched` cards. None names either file, except this card's own claim. 3. **Holds, with nothing to keep.** All 12 sites are comments. No test string, exported string or `describe()` text carries one, and no test or script reads any of them by literal. 4. **Holds.** No generated reference page projects these lines; `check:docs` is green with no regeneration. ## Deviations - None to the file surface: the 12 claimed lines and one changeset, no generated page needed. - Commit trailers follow AGENTS.md's model-free pair (`Claude-Session` plus `Co-authored-by: Claude`); the pre-push trailer check passed on every push. ## Acceptance notes **What stays for later stages.** The gate's census at this PR's head (base `0f6dcac5e9` plus this PR) reads **248** dead sites (29 numbers) in `packages/spec/src`. The only `packages/spec/src` change `main` has made since the base (objectstack-ai#20610's migrations entry and registry) adds four live numbers and removes none, so 248 also stands at the tip `7510663c87` plus this PR: - `migrations/` **233**: objectstack-ai#20233 edits the same entry files (PR objectstack-ai#20607 holds `migrations/registry.ts`). - `conversions/registry.ts` **12**: PRs objectstack-ai#20570 and objectstack-ai#20587 hold it. - `integration/connector.zod.ts` **1**: PR objectstack-ai#20587 (objectstack-ai#20287). - `data/api-derivation.ts:163` (objectstack-ai#6259) and `identity/identity.zod.ts:230` (objectstack-ai#8715), **1** each: kept because tests read them by literal, so removing them is form D. **Outside the gate's census: test files.** The gate defers `*.test.ts`. The same six dead numbers still stand at 15 comment sites and 10 test-title strings in `packages/spec/src` test files: - `data/analytics-strictness-batchd.test.ts:96` (comment, objectstack-ai#10194) and its title `:93`. This file is in the `analytics*` set stages 3 and 4 excluded while PR objectstack-ai#20458 held it; `analytics-date-range-two-bound-window.test.ts` and `cube-member-inner-name-retirement.test.ts` were in that set too and are not re-measured here. - The package root: `compose-stacks-action-echo.test.ts:20`, `:34`, `:200` (objectstack-ai#14686) and titles `:176`, `:224`; `compose-stacks-action-key-collision.test.ts:3` (objectstack-ai#14662); `stack-top-level-strict.test.ts:103` (objectstack-ai#10485) and title `:128`; `type-alias-convention.pin.test.ts:257`, `:1572`, `:1937` (objectstack-ai#10485). - `shared/`: `metadata-collection.test.ts:250`, `metadata-url-spelling.test.ts:51`, `:72`, `:168` (objectstack-ai#10485), `:257` (objectstack-ai#10194), title `:254`. `automation/sync-retirement.test.ts:207` (objectstack-ai#10485). - `kernel/`: `manifest-unknown-keys.test.ts`, four titles (objectstack-ai#14192); `metadata-type-schemas.test.ts:422`, a title (objectstack-ai#10194). - Stage 6 took the package root, `shared/` and `automation/` through the gate's census, which never lists a test file, so test-file comment lines there may carry other dead numbers as well. That wider population is not measured here. **Outside `packages/spec/src`.** The same six numbers stand at 44 more sites (`packages/{metadata-protocol,objectql,rest,runtime,cli,core,metadata,qa}`, `examples/`, `scripts/`, `packages/spec/scripts/`), and at 19 sites in `migrations/` (the objectstack-ai#20233 area). **Rung.** The objectstack-ai#10485 retirement also has the ADR-0087 D3 entry `stack-themes-carrier-retired`, which `:423` already names. This PR takes the commit rung, as stages 1–6 did. **Wording, each true of its commit.** `:3037` and `:3194` now read "commit 279431e's same-key refusal": the refusal that commit added, in lines `773a99960a` wrote. `:1233` reads "commit 4d0d944 closed `ManifestSchema`", in a line `c78c9180de` wrote. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #14847
Summary
defineStackends withmergeActionsIntoObjects: every standalone action carryingobjectNameis copied into that object'sactionson the way out, and the standalone stays instack.actions.composeStacksconcatenates its inputs'actionsand ended with the same merge — so each bound action was appended to its object a SECOND time, beside the copy the input's own build had put there.mergeActionsIntoObjectsis now idempotent over its own output: a bound action the object already carries by identity is not appended again. Identity against the standalone list, deliberately not equality — the only way an entry ofstack.actionsis the very same object as an entry ofobject.actionsis that a previous merge put it there. Nothing else in the merge moves: order, theordersort, the untouched-object fast path.Measured on
main@6392b9c2(BASE),defineStackoutputs as inputs — before / aftera_item.actions = [dup_x/BOUND, dup_x/BOUND][dup_x/BOUND]manifest: 'preserve'objects;packages[].manifest.objectscarried ONE each (the two halves disagreed)[dup_x/BOUND], both halves agreeembNand bindingbNto one object,override/mergeshared.actions = [emb3/EMB, b3/BOUND, b1/BOUND, b2/BOUND, b3/BOUND][emb3/EMB, b3/BOUND, b1/BOUND, b2/BOUND]— the surviving stack's declared actions plus each concatenated standalone oncedefineStack(composeStacks([a, b]))'a_item:dup_x' is declared 3 timesis declared twice, the identical linedefineStack(a)gets for a lone built inputdefineStack(a)(a lone built input)declared twice(the landed #14686 pin atstack-duplicate-action-key.test.ts:236)'t_item:tw' is declared twice)actions.0: Unrecognized key(s) on this action: \__echo``strict: false, ONE action object in both positionsexamples/app-multi-packagecomposed object (the objectcompile.ts:318parses), JSON sha256b1225296475ff3c7…(7351 bytes, both objects carry 0 actions)The fork, decided by measurement (dispatch ruling)
Both directions can satisfy (a) the pins and (b) the twin refusal — both would key on identity, since a marker does not survive the strict parse. They differ on the published shape and on what they touch:
collectComposedActionKeyCollisionsand its message untouched (it runs before the merge). Smallest shape change.mergeObjectschanges the input of feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key #14854's walk and therefore its message — the landed pin'com.example.b' (stack #1) at stack.actions[0] + objects['shared'].actions[0]names the echo site — and stripping after the walk but before the final merge is the same identity skip with a reorder of the surviving stack's own bound action ([emb3, b1, b2, b3]instead of[emb3, b3, b1, b2]). Larger shape change for the same result.Criterion (c), round-trip through
defineStack, holds in the only form the landed rulings allow.defineStackrefuses ANY built stack that binds an action — pinned atstack-duplicate-action-key.test.ts:236("a built stack fed back in … is refused — author the source shape, not the artifact") — sodefineStack(composeStacks([a, b]))cannot parse when an input binds an action under either direction without weakening that rule, which is ⛔. What this change restores is that composition adds no refusal of its own: with no bound action the composed output parses cleanly (as before), and with bound actions it is refused with the samedeclared twiceline a single built input gets, no longer3 times. Both are pinned. The card's and the triage's "not round-trippable through the door that built its inputs" is therefore a property of every built artifact, not of composition — noted for the contract review, not acted on here.Rulings honoured
collectComposedActionKeyCollisions: untouched; its message pins all green (23/23).objectConflict: 'merge'dropping non-fieldskeys): out of scope: composeStacks objectConflict: 'merge' merges fields only — the later object's actions (and every other key) replace the earlier package's wholesale, silently dropping its embedded actions #14848 remains open and is not touched here;mergeObjectsis not edited.Changes
packages/spec/src/stack.zod.ts—mergeActionsIntoObjectsfilters the bound list by identity against the object's currentactionsbefore appending; TSDoc states the idempotence;composeStacksgains a step-7 comment at its final merge. 31 insertions, 4 deletions.packages/spec/src/compose-stacks-action-echo.test.ts— NEW, 12 pins: once under default /preserve/ three stacksoverride/merge; add-on binding to another stack's object;orderacross the once-merged set; twin refusal; marker refusal;strict: falseshared reference; idempotent re-merge by reference; round-trip with and without bound actions.packages/spec/src/compose-stacks-action-key-collision.test.ts— only the app-multi-package mirror pin that named composeStacks re-merges bound standalone actions that defineStack already copied into their objects — every bound action appears twice in the composed object's actions #14847 as "the measured shape, not the contract" is tightened totoEqual; no refusal message moves..changeset/compose-stacks-action-echo.md—@objectstack/specpatch.Verification (all on
f1ed87e8, the head; throughscripts/pm/os-verify-lock.sh, shared-box seconds)pnpm --filter @objectstack/spec build—check-dts-emitted: 34/34,.build-input-hash 6192f26e…; dist carriesbase.includes(action)(1) and the old append form (0).pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2over the 13compose-stacks*/stack*/assembled-package-bodysuites —Test Files 13 passed (13),Tests 339 passed (339).pnpm --filter @objectstack/spec typecheck— green, incl.check:test-typecheck: OK — 54 file(s) / 261 error(s) … held;tsc -p tsconfig.test.json --listFilesnames the new test file (1 hit), so the test layer verdict covers it.pnpm --filter @objectstack/spec check:generated—✓ All 15 generated artifacts are up to date(check:api-surface,check:docs,check:authorable-surface,check:liveness… all ✓).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the actual diff (58 commands): 52 green at exit 0 — incl.check:nul-bytes,check:adr-0087-registration,check:empty-changeset,check:changeset-no-major,check:closing-keyword-parity,check:cross-package-test-inputs,check:test-source-alias,check:engine-double-contract,check:type-check-coverage, and every speccheck:*in the list. The script flagged its tree as 4 commits behindorigin/mainwith two gate scripts changed upstream (check-cross-package-test-inputs,engine-double-contract.pinned.json); CI runs the current ones. NOT MEASURED, each by its own verdict text (exit 3 / prerequisite, not a finding):check-dev-prereqsandcheck:dual-build-cjs-loadsandcheck:type-check-debt(need the whole workspace built — CI owns),check-test-completeness(needs a turbo test log — its usage text says record NOT MEASURED locally),scripts/pm/check-half-states.mjs(live-repo PM sweeper; timed out at 180s with and without--use-env-proxy).check:doc-formula-expressionswas re-run after building@objectstack/formula— see the report comment for its line.Reverse verification (fix committed first)
Mutation: the identity filter removed (
const fresh = actionsByObject.get(obj.name) ?? [];— the pre-fix append), proven on disk by anchor counts (injected=1 removed=0) and a differing blob hash (790cfe5c…vs HEAD8c27145e…). Prediction recorded in the script header before the run: 8 red / 4 green controls in the new suite, 1 red in the collision suite (the tightened mirror pin), 0 red in the #14686 suite. Observed:compose-stacks-action-echo.test.ts (12 tests | 8 failed),compose-stacks-action-key-collision.test.ts (23 tests | 1 failed),stack-duplicate-action-key.test.tsall green —Tests 9 failed | 42 passed (51). No dist leg: these suites import./stack.zod(relative source), not the packageexports, so no rebuild was needed anddist/stayed on the fix throughout (marker re-checked after restore). Restore bygit checkout HEAD -- packages/spec/src/stack.zod.tsunder an EXIT/INT/TERM trap with absolute paths, proven bygit hash-objectequal to the HEAD blob andgit diff HEAD --statEMPTY.Contract review notes
actionsis the previous output minus the duplicate entries. Accept set unchanged in both directions.strict: false, one action object placed in both positions is now carried once (before: twice). Pinned and documented; flagged here for the reviewer.defineStack(composeStacks([a, b]))parses when no hand-written twin exists") is falsified by the landed feat(spec): defineStack refuses two actions that resolve to one scope-qualified runtime key #14686 pin whenever an input binds an action; the pins assert the achievable form described above.🤖 Generated with Claude Code
https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i
Generated by Claude Code
Generated by Claude Code