fix(#2789): scope the emitted-drift ack to the diff that introduced it (#2803)

* fix(#2789): scope the emitted-drift ack to the diff that introduced it

Every input to `diffEmitted` is base-relative -- `baseline` vs `current`,
`changedPaths` from `git diff base...HEAD` -- except the ack set, which
was read absolutely, from the working tree only. A differential machine
consulting a non-differential input.

So `staleAcks` asks exactly one question, "did a delta consume you?", and
that cannot distinguish an ack that never explained anything (an
authoring mistake) from one whose ripple is now absorbed into the base
(the ack's SUCCESS condition). After merge an ack is in the second state
but reports as the first.

The trigger is ordinary. Actions sets GITHUB_BASE_REF on pull_request
events only, so a push to `next` falls through to origin/next -- the very
commit under test. Both sides build identical content, no deltas remain,
and every live ack is reported stale. PR #2768 acked a deliberate 40866
-> 42020 byte growth, was green on its own lane, and reddened `next` the
moment it merged. It also reds every PR branching off the poisoned base,
and since publish-emitted-baseline is gated on the test job, it blocked
baseline publication too.

Give the ack the base side it was missing. `diffEmitted` now takes
`baseAck` -- the same document at the base ref, via `readAckFileAtRef`.
An entry already present there is SPENT: it may no longer consume a delta
and is never reported stale, only surfaced as `spentAcks` for tidying. An
entry new or reworded in this diff stays live, and if nothing consumes it
that genuinely fails, with blame on the author who just wrote it.

This closes a hazard the IMPLEMENTATION named but could not prevent -- a
leftover ack silently pre-clearing the next ripple on its path. (ADR-2719
§3 asserted only that TOUCHING the file is the alarm; its residual-risk
list never covered pre-clearing, and §3 now carries an amendment.)
Verified against the two-PR laundering sequence -- land an innocuous ack,
then change the artifact -- which passed silently before and now fails on
both the hash pass and the size ratchet.

Three things the design has to get right, each of which was wrong first:

  - A read failure on the base document THROWS; only absence-at-the-ref
    returns null. Returning null on error LOOKS armed (every entry stays
    live) but a live entry's defining power is that it CONSUMES a delta,
    so null is armed on the staleness axis and DISARMED on consumption --
    silently the whole pre-#2789 gate. `git show` cannot tell absence
    from fault, so absence is established with `ls-tree`.
  - Re-arming a spent ack costs actual PROSE. Internal whitespace and the
    zero-width family collapse, and `runtime` is not compared: a doubled
    space, an invisible character, or a decorative field would otherwise
    re-arm an ack whose justification still describes the previous
    ripple, showing a reviewer nothing.
  - `baseAck` is REQUIRED once an ack declares entries -- omission is an
    error, not a silent "inherit nothing" -- so a dropped argument fails
    loudly instead of quietly restoring this bug with the suite green.

Because a corrupt document ON THE BASE is expensive (the loud base-side
failure reds every ack-carrying PR), scripts/lint-emitted-drift-ack.cjs
blocks one from landing. It is standalone rather than importing parseAck
-- scripts/ ships in the npm package and tests/ does not -- so a parity
test runs both surfaces over one corpus and fails on divergence; it
caught one immediately, a `null` document, now classed as policy rather
than schema. Deadlock is separately foreclosed: a tree carrying no ack
never reads the base, so the PR that DELETES a corrupt file still lands.

`readAckFileAtRef` takes an injected git runner so all four branches are
tested deterministically; it never executes in the remote runner, where
the real-tree test skips for want of a base ref. It also refuses an
option-shaped ref, since execFileSync's array form stops shell
metacharacters but not git's own option parsing.

Rejected: skipping the differential when base == HEAD. It treats the
symptom, costs real coverage on the push-to-next lane, and does nothing
about the downstream PRs the same flaw was reddening.

Deletes the now-spent tests/emitted-drift-ack.json, and updates the
CONTEXT.md canon and ADR-2719 §3: presence is no longer the alarm -- a
LIVE entry is, and a spent one is inert.

Closes #2789

* chore(#2789): backfill changeset PR number
This commit is contained in:
Tom Boucher
2026-07-28 21:28:10 -04:00
committed by GitHub
parent d626dbc6e3
commit 1e3c995e6f
8 changed files with 870 additions and 12 deletions

View File

@@ -75,6 +75,10 @@ The design property that matters: the acknowledgment file appears in the changed
This does not make a bad change impossible. It converts a silent regeneration into a conspicuous declaration. That is the intended strength, stated plainly rather than overclaimed.
> **Amendment (#2789):** *touching* the acknowledgment is still the alarm, and is now strictly harder to fake. Mere **presence** is not, and treating it as such was a real defect. The document was read only from the working tree while every other input to the law is base-relative, so `staleAcks` — "acks no delta consumed" — could not tell an ack that never explained anything from one whose ripple had been **absorbed into the base**, which is the ack's success condition. Merging an acknowledgment therefore reddened `next` and every PR branching off it (#2768).
>
> An ack is now scoped to the diff that introduced it: `diffEmitted` also takes the document at the base ref, and an entry already present there is **spent** — it can no longer consume a delta and is never reported stale, only listed for tidying. New or reworded entries stay live, and re-arming costs actual prose (internal whitespace, invisible characters and the unread `runtime` field are all normalized away), so the conspicuous declaration this section asks for cannot be forged with a zero-information edit. That also closes a hazard the *implementation* named but could not prevent — a leftover ack silently pre-clearing the next ripple on its path; note this ADR's own residual-risk list never covered it. `scripts/lint-emitted-drift-ack.cjs` refuses the merge if a malformed document would reach the base, where the base-side reader's deliberate hard failure is expensive.
### 4. The size ratchet folds into the same machine
`workflow-size-baseline.json` conflicts on 7 of 7 — deleting only the golden fixtures would leave every affected PR still blocked. The attribution law does not transfer to it (growth is trivially attributable to the edit that caused it), so instead the same differential machine reports growth with exact byte deltas, and growth requires the same acknowledgment entry.