# A gate that recognises one error is blind to every other

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0161-a-gate-that-knows-one-error-is-blind-to-the-rest.md

---

# 0161 — A gate that recognises one error is blind to every other

## Decision

The consumer lane's declaration check **subtracts** what is knowingly accepted
and fails on the remainder, instead of **matching** the one diagnostic shape it
was written for. An unresolved optional peer is still a judgement call and still
lives in `ACCEPTED_UNRESOLVED`; everything else the compiler says about a packed
`stitchkit` declaration now fails the lane.

## What it missed

`createAsyncOperationSnapshotSchema` builds its object shapes with a conditional
spread:

```ts
const progress = config.progress ? { progress: config.progress.optional() } : {};
z.object({ phase: z.literal('pending'), ...progress });
```

TypeScript infers that shape as a **union** — one branch with `progress`, one
with `progress?: undefined` — and zod constrains a shape to
`Record<string, $ZodType>`, which the second branch does not satisfy. Checking
source never surfaces it: the shape stays internal and the check passes. Writing
a declaration does surface it, once per phase, five times, in
`dist/tools/async-operation-contract.d.ts`. Any consumer with
`skipLibCheck: false` reads them, and `stitchkit/tools/contract` exists
*specifically* for consumers.

It shipped in 0.78.0 and again in 0.79.0. Not because no gate ran: the consumer
lane runs `tsc --skipLibCheck false` against the packed tarball for every
fixture, and had been printing those five errors the whole time. The lane then
walked the output like this:

```js
for (const line of libCheck.split('\n')) {
  if (!line.includes('node_modules/stitchkit/')) continue;
  const module = line.match(/Cannot find module '([^']+)'/);
  const namespace = line.match(/Cannot find (?:namespace|name) '([^']+)'/);
  // …anything that matched neither was discarded
}
```

The check was right, the target was right, the output was in hand. The parser
recognised one error class, and a diagnostic that was not an unresolved
reference left no trace — a run with five defects in it printed exactly what a
clean run printed.

## Why this shape and not a longer pattern list

Adding `TS2344` to the patterns would fix this instance and reproduce the
defect: the next unfamiliar code is silently discarded again, and the gate keeps
reporting clean. The failure is not a missing pattern, it is a filter that
decides what to *see*. Inverting it makes the unknown loud by default, and puts
the burden on the accepted list — which is short, reviewed, and already the
place where this lane records a judgement.

The measured cost of the strict shape is bounded: across all 29 entrypoints,
`skipLibCheck: false` produces exactly zero diagnostics in stitchkit's own
declarations once the snapshot factory is fixed. The lane is not being asked to
tolerate a backlog.

## The fix under it

`createAsyncOperationSnapshotSchema` is two overloads, each returning a named,
written-out union — `AsyncOperationSnapshotSchema<TFailure>` and
`AsyncOperationSnapshotSchemaWithProgress<TProgress, TFailure>`. The conditional
spread stays in the implementation, where it is a value and correct, and never
reaches a declaration.

Not a normalised key. Substituting `z.never().optional()` for the absent
`progress` would make one uniform shape and delete the whole problem — and would
turn a snapshot that used to *strip* an unexpected `progress` into one that
rejects it. The defect is in the types; the runtime does not move to fix it, and
`tests/async-operation.test.ts` now pins that strip.

The same spread had a second effect nobody had noticed, and it is the one that
makes this a minor rather than a patch. Narrowing `config.progress` inside the
ternary widens it back to a bare `ZodType`, so the declaration erased the type
argument: `z.output<typeof schema>['progress']` was `unknown` in every published
copy. Measured against the 0.79.0 tarball from the registry rather than from
memory — `const s: Snap = { phase: 'running', progress: 'a string' }` compiles
there and is `Type 'string' is not assignable to type '{ done: number; }'` now.
An error union that does not typecheck is loud; a type quietly widened to
`unknown` is not, and it had shipped for longer.

## And the entry was covered by a coincidence

Inverting the filter is only half of it. The five errors surfaced in the `full`
fixture — the one that installs every optional peer — because it imports
`stitchkit/tools`, whose declaration re-exports the contract module. No fixture
named `stitchkit/tools/contract`, so `dist/tools-contract.d.ts` itself was never
in any program, and the coverage of the module behind it would have vanished
silently the day `full` stopped importing `stitchkit/tools`.

An entrypoint built for a peer-free browser client, checked only through the
fixture that installs everything, is being checked for the wrong thing.
`fixtures/minimal/src/tools-contract-conformance.ts` imports it by name, in the
peer-free fixture, names both return types, and runs the parses — so the entry
is now anchored rather than reached sideways.

## And it should not have to wait for a release to say so

The consumer lane is the right net and the wrong moment. It packs a tarball,
installs it into five fixtures and typechecks each — a minute of work that runs
when a release is being cut, which is the last place anyone wants to learn that
a declaration does not compile. The same question costs seven seconds against
`dist`, and `check-declarations-strict.mjs` now asks it inside `build`: one
generated module re-exporting all 29 entrypoints, `skipLibCheck: false`,
failing on any diagnostic whose file resolves inside our own `dist`.

The lane keeps its job. It checks the *packed* artifact with real installs and
real optional-peer matrices, which a build cannot; this one catches the class
four seconds after the file is written, where the fix is an edit rather than a
second release commit.

Writing it reproduced the same defect twice, which is the argument for positive
controls better than any reasoning about them:

- The first filter tested whether the whole line contained the `dist` path.
  `tsc` quotes paths inside its own prose — "Did you mean to import
  'dist/index.js' instead?" — so the probe's own 29 errors were reported as the
  package's.
- The second resolved that by keying on the diagnostic's file position, and
  compared it to an **absolute** `dist` path. `tsc` prints a diagnostic's file
  relative to its working directory, so the comparison matched nothing at all
  and the gate reported 29 clean entrypoints while holding five errors in its
  hand. Nothing about the output distinguishes that from a real pass.

Both were caught by running the gate against the 0.79.0 declaration, whose
answer was known in advance. Neither would have been caught by reading it.

## Consequence

- A diagnostic in a packed declaration fails the release, whatever its code.
- A diagnostic in an emitted declaration fails the **build**, seven seconds in.
- A newly unresolvable optional peer is a listed decision, as before.
- The no-progress overload has runtime tests. It had none: every caller in this
  repository, in the guide and in the examples configures `progress`, which is
  why the broken half of the union was written down for two releases and never
  once executed.

## Related

- ADR 0156 — a schema a browser cannot import is not a contract (why this
  entrypoint exists at all).
- ADR 0158 — a gate that does not run the code is measuring a proxy (the same
  failure one layer out: the check was real, what it looked at was not).
