Files
msd-core/tests/emitted-drift-acks
Tom Boucher 86fa2917d7 enh(#3866): dispatch step and contribution hooks at verify:pre (#3869)
* test(#3866): pin that verify:pre must dispatch every hook kind

verify-work.md's verify_pre_hooks step dispatches only `kind == "gate"`, so
getWiredKinds reports verify:pre -> {gate} and gen-capability-registry rejects
any capability declaring a step or contribution there. The verify lane is
therefore closed to capabilities that want to contribute to what UAT covers
rather than refuse to let it start.

Failing-first: the step, contribution, and exact-kind-set rows are RED; the
pre-existing gate row is a green regression pin so the new arms cannot orphan
the arm verify:pre already had.

Refs #3866

* feat(#3866): dispatch step and contribution hooks at verify:pre

verify_pre_hooks dispatched `kind == "gate"` only, so getWiredKinds reported
verify:pre -> {gate} and gen-capability-registry's validateHooksWired rejected
any capability declaring a step or contribution there. A capability could
refuse to let UAT start; it could not contribute to what UAT covers.

Add contribution and step arms mirroring execute:wave:post, deferring to
references/loop-hook-dispatch.md and carrying its ref.command in-context
validation guard ahead of any shell-use prose. A verify:pre step is advisory:
it never blocks the start of UAT and an erroring step is routed by its own
onError. The gate arm and its check guard are untouched.

Give extract_tests an additive consumption seam for the artefacts those steps
declare via the existing steps[].produces field -- no new registry field, no
new ordering, no invented filename. Manifest-supplied artefact names are
validated in-context against an allowlist and resolved only inside PHASE_DIR.
With no producing step the derivation is unchanged, pinned by test rather than
asserted in prose.

Review findings folded in: the artefact-name allowlist (isolated adversarial
pass), the artefact-shape contract and the seam-inertness tests (spec axis),
and the reference/how-to split so one constraint has one source of truth
(standards axis).

Closes #3866

* chore(#3866): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
2026-08-25 18:07:29 -04:00
..

tests/emitted-drift-acks/

Per-PR acknowledgment fragments for the differential attribution check (tests/emitted-attribution.test.cjs, ADR-2719 / #2789 / #2914).

This directory being empty is the healthy steady state. A fragment appearing in a diff is the alarm; a fragment sitting here on next is spent cruft. README.md is not a fragment — every reader filters on .json — and exists so the directory (which CONTEXT.md and CONTRIBUTING.md both reference by path) survives the sweep that empties it.

The lifecycle, in three steps

  1. The gate names its own remedy. When an emitted-artifact hash moves, or a gsd-core/workflows/*.md / agents/gsd-*.md file grows, and your diff cannot explain it, the failure output tells you which key to use and prints a minimal valid document to paste. Create a NEW fragment named for your issue or PR (<NNNN>-<slug>.json) — never reuse someone else's, and never revive the legacy single tests/emitted-drift-ack.json.
  2. Note the two key spaces. The message says which one applies. An unattributable hash ripple is keyed on the emitted path (skills/gsd-add-tests/SKILL.md); growth is keyed on the bare filename as it appears under gsd-core/workflows/ or agents/ (explore.md).
  3. Delete the fragment once it has merged (#3078). Every entry is scoped to the diff that introduced it, so the moment it lands on next its prose is already at the base — it is spent and can no longer clear anything, while still owning its path keys. The guard-no-ack-on-next job reds next and prints the exact git rm. Run it.

Why the sweep exists

Fragments end the file conflict the single shared document caused. They do not end the key conflict: two ack sources may never name the same path, and that is a hard, loudly-reported error. So a fully-spent fragment left here walls off every path it owns — the next PR to grow one of them can declare it neither in the owning fragment (spent, gates nothing) nor in its own (duplicate). #2914 assumed a persisting fragment was harmless; #3078 measured the cost at 45 fragments owning 403 paths and made the guard sweep them.

A partially spent fragment is deliberately left alone. That asymmetry is what keeps the re-arm route working: appending prose to a live entry re-arms it, and re-arming deliberately costs an actual new sentence — the comparison strips zero-width characters and collapses whitespace precisely so a zero-information edit cannot fake one.

Never pin a fragment in a test

A fragment is deleted the moment it has merged (see step 3 above), so any test that asserts one exists, or asserts its contents, will fail the instant guard-no-ack-on-next sweeps it — and that failure has nothing to do with the behavior the fragment once explained. This has already cost two suites: tests/emitted-attribution.test.cjs's three #2914 migration pins, and tests/agent-tracked-source-rule.test.cjs's #3645 growth-ack pin. What a test may legitimately assert is the BEHAVIOR the ack explains, or the guard's own verdict (assertNoAllSpentFragments, assertAbsentOnNext) — never the paperwork.

Do not regenerate anything

There is no baseline file to re-run a generator over; #2724 deleted it. If you find yourself hunting for one, that is the predictable wrong guess.

See CONTRIBUTING.md → "Editing shipped content", docs/TESTING-SUITES.md, and CONTEXT.md's RULESET.EMITTED_ATTRIBUTION for the full model.