Skip to content

feat(drift)!: schema-anchored, diff-based drift detection (ADR 0015)#331

Merged
rejifald merged 4 commits into
mainfrom
worktree-drift-variance-v2
Jun 28, 2026
Merged

feat(drift)!: schema-anchored, diff-based drift detection (ADR 0015)#331
rejifald merged 4 commits into
mainfrom
worktree-drift-variance-v2

Conversation

@rejifald

@rejifald rejifald commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Closes #327.

What & why

Drift detection was snapshot-based: each response's shape was compared against a committed <name>.contract.json baseline. A single baseline can't tell real drift from natural response variance, so optional-absent / nullable-null / empty-or-heterogeneous-array all surfaced as false positives (#327).

This replaces it with schema-anchored, diff-based drift (ADR 0015): the declared output schema is the contract.

  • Validation = the hard tier. A missing-required or incompatible value throws (change: 'invalid', error); the call now returns the validated value — coerced, defaulted, unknown keys stripped — so the result matches the declared type (a soundness fix; it previously returned the raw body).
  • Drift = the soft tier. Once validated, the engine diffs the raw body against the validated value (a new zero-dep diff.ts): undeclared (info), coerced (warn — a wire-type shift validation hides), defaulted (verbose). All non-fatal, []-collapsed and deduped, vendor-neutral (any validator returning a parsed value).
  • Variance can't false-positive: an .optional()/.nullable()/array schema validates clean → no finding.
  • Config: DriftOptions = { ignore, severity }. ignore silences acknowledged-but-unconsumed paths (kept out of the typed schema); severity filters (a level / list) or re-levels (a per-kind map).

Breaking changes (pre-1.0)

  • drift() no longer accepts critical / watch / onNew / snapshotFile / readonly / onMissing.
  • The stitch drift generate CLI command is removed.
  • A call returns the validated value, not the raw body.
  • A missing required field surfaces as invalid (make a field required to fail, .optional() to tolerate).

Migration: drop the snapshot options; make the fields you depend on required; use ignore/severity for the soft signals.

Out of scope (follow-ups)

  • ADR 0016 — expose the raw body + drift findings on a non-enumerable result field for after-the-fact analysis (decided to keep separate; needs its own design pass).
  • A best-effort "rescue" recovery feature (structural re-wrap / scalar↔array repair) — its own decision.

Verification

  • core: 1072 tests, tsc (src + test), ESLint, tsd, attw exports — all green
  • new diff.ts: 34 unit tests
  • integration packages (pino / sentry / nest / fastify) drift fixtures updated — all green
  • docs build (twoslash, 336 pages), docs-links, content manifest — all green
  • ADR 0015 added; ADR 0005's drift clauses superseded; READMEs, the drift guide / two recipes / error page, OVERVIEW / DESIGN / FEATURE-LENSES, HARNESS-API, and the schema-drift blog post all re-synced to the new model

🤖 Generated with Claude Code

rejifald and others added 2 commits June 28, 2026 16:15
Replace the snapshot drift mechanism with schema-anchored drift. Validation is
the hard contract (throws on a missing-required/incompatible value and returns
the validated value — coerced, defaulted, unknown keys stripped); soft drift is
the diff of the raw body against that validated value, classified undeclared
(info) / coerced (warn) / defaulted (verbose) and never fatal. This eliminates
the variance false positives a single snapshot baseline produced (#327): an
optional field absent, a nullable null, an empty/heterogeneous array all
validate clean and report nothing.

- core: new zero-dep diff.ts + classifyDiff; engine returns the validated value
  and diffs raw-vs-validated into drift events; DriftOptions = { ignore, severity }
- remove the snapshot subsystem: snapshotFile / readonly / onMissing / learn,
  the `stitch drift generate` CLI, and the critical / watch leveling
- vendor-neutral: works for any validator returning a parsed value (no Zod codes)
- docs: ADR 0013 (supersedes the drift clauses of 0005), READMEs, the drift
  guide / two recipes / error page, OVERVIEW / DESIGN / FEATURE-LENSES, and the
  schema-drift blog post

BREAKING CHANGE: drift() no longer accepts critical / watch / onNew /
snapshotFile / readonly / onMissing; the `stitch drift generate` command is
removed; a call now resolves to the validated value (coerced/defaulted/stripped)
rather than the raw body; a missing required field surfaces as `invalid` (make a
field required to fail, optional to tolerate). Closes #327.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR #328 (open) already claims ADR 0013 (gen) and 0014 (publishing). Renumber the
schema-anchored drift ADR to 0015 and update all references; the planned
expose-raw follow-up becomes 0016.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rejifald rejifald changed the title feat(drift)!: schema-anchored, diff-based drift detection (ADR 0013) feat(drift)!: schema-anchored, diff-based drift detection (ADR 0015) Jun 28, 2026
rejifald and others added 2 commits June 28, 2026 16:35
Resolve the one conflict in catch-a-breaking-api-change.mdx: keep this
branch's schema-anchored diff-model prose (the snapshot model #329 had
only editorially polished is gone) and fold in #329's "watch a field
erode" cross-link. Re-sync the docs IA manifest's drift entries (guide
title/description, the two recipe descriptions, the STITCH_DRIFT error
description) to the new model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The merge left the rewritten drift table's separator row unaligned;
`pnpm check:format` (prettier --check) flagged it. Alignment only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drift baselines conflate natural response variance with real drift (single-sample false positives)

1 participant