Skip to content

feat(drift): ironclad drift-detection hardening (Phases 1-4)#297

Merged
jpr5 merged 32 commits into
mainfrom
drift-ironclad-impl
Jul 15, 2026
Merged

feat(drift): ironclad drift-detection hardening (Phases 1-4)#297
jpr5 merged 32 commits into
mainfrom
drift-ironclad-impl

Conversation

@jpr5

@jpr5 jpr5 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Implements the reviewed aimock Drift-Detection Ironclad hardening spec: https://app.notion.com/p/39d3aa38185281c582b1d819c2842518

Built via 4 gated waves (17 micro-tasks), each red-green against the real failure surface, per-wave reviewed to zero, plus a final full-branch integration CR. 21 files, ~+2238/-288. 224 drift tests green.

What changed, by wave

  • Wave A — collector taxonomy + primitive. Pure computeExitCode (crit>0→2, quarantine>0→5, agUiSkipped→1, else 0); collectDriftEntries now quarantines unmapped/unparseable failures (capturing file:line) instead of throwing — the exit-1 crash that hid incident fix: latency chunkSize replaySpeed logLevel in startFromConfig #294's root cause is gone. Report schema gains DriftClass/per-item id/class/quarantine[]. Shared normalizeModelFamily(id, provider) primitive (voice-models migrated onto it, behavior-identical).
  • Wave B — exit-5 threading + W1 /models rewrite. drift-retry + both workflows thread exit-5 (quarantine → run fails, no auto-fix abort). Model identity no longer scrapes prose/source (the incident root cause): enumerate live /models → normalize → subtract curated include/exclude → unclassified delta is the signal, with an LLM-triage seam. models-scrape.drift.ts retired.
  • Wave C — classified alerting. InfraError carries a structured HTTP status; Slack summary emits a closed class enum (real-drift / false-positive / infra-transient / stale-key / quarantine) with per-item id + file:line; key-freshness preflight fails loud on 401/403 (never a green skip); notify routes exit-5 → quarantine before the HTTP-drift fallback.
  • Wave D — delta-gated PR check. New drift-live-pr job (pull_request, path-filtered, no secrets on forks) runs base+head and blocks only on failures the PR introduces (new-in-head), regardless of class — a drift-code PR is judged on "did your change break the detector," not "is the world drifting today." Base-present failures are advisory. Fork PRs use a maintainer dry-run.

Bugs the review process caught (not shipped)

  1. Anthropic -YYYYMMDD normalize gap — dated Claude snapshots wouldn't collapse to their family → would false-positive (incident-2 class, untested provider). Fixed via the provider hook.
  2. Class-gating hole — a first cut gated on collector-assigned class; a new-in-head real-drift would've been advisory → would have re-greenlit the exact fix(drift): flag new voice/audio model families beyond the "realtime" substring #292 incident. Fixed to gate purely on new-in-head.
  3. NUL-byte in drift-delta.ts rendered the module binary/unreviewable in diffs — caught only by the final full-branch CR.

Follow-up before merge

  • Keyed CI run required: the live /models provider blocks were skipped in the impl env (no keys). A keyed CI run is the remaining real-surface validation.

DRAFT — awaiting review before merge to main.

jpr5 added 30 commits July 14, 2026 17:20
Extract the OpenAI voice-model dated-snapshot/build-tag family-strip loop
into a side-effect-free normalizeModelFamily(id, provider) shared core. The
provider argument is a forward hook; the strip is applied identically for
all three providers today, so normalizeModelFamily(id, "openai") is
byte-identical to normalizeVoiceModelFamily(id).
…imitive

normalizeVoiceModelFamily(id) is now a thin wrapper over
normalizeModelFamily(id, "openai"); the duplicated dated-snapshot/build-tag
strip loop moves to the shared model-family primitive. Behavior-preserving:
knownVoiceModelFamilies is byte-identical and all voice-model unit tests plus
the ws-realtime drift selection stay green. Adds a characterization test
asserting the wrapper is byte-identical to the shared primitive.
…opagate immediately

Add EXIT_QUARANTINE=5 constant and quarantine?: boolean field on RetryResult.
retryUntilStable now recognises exit 5 as a distinct terminal outcome: it does
not retry (unlike exit 2) and marks quarantine:true on the result. The crash
branch remains for all other non-{0,2,5} codes. main() already propagates the
exit code via process.exit(result.exitCode) so exit 5 reaches the caller.

Test: inject collector→5; RED (pre-change) quarantine field was undefined;
GREEN asserts exitCode===5, quarantine===true, calls===1.
…workflow

Add elif branch for EXIT_CODE==5 in the collector step. Exit 5 means the
collector quarantined unparseable output — it is not a script crash, so the
workflow should continue rather than abort. Exit 1 (and all other non-{0,2,5}
codes) remain fatal. The exit-2-only auto-fix gate (step 2 check) is unchanged.

Red/green shell-harness: pre-change EXIT_CODE=5 routed to abort (fell into
the non-0 catch-all); post-change routes to continue.
Add side-effect-free model-registry.ts exporting per-provider include/
exclude family Sets (seeded through normalizeModelFamily so membership is
normalization-consistent and idempotent) plus a provider-agnostic
NON_MODEL_TOKENS allowlist carrying gemini-interactions.

include = text-generation families aimock mocks (derived from conformance
tests/README/fixtures); exclude = retired/preview/non-text/voice families.
The union is what the drift check (B4.2) subtracts against normalized live
/models; leftover families are the drift signal.

Unit test proves registry shape: include membership via normalized id,
NON_MODEL_TOKENS carries gemini-interactions, no family on both include
and exclude, and seeds are idempotent under normalization.
… workflow

Add elif branch for EXIT_CODE==5 in the collector step of the drift job. Exit 5
emits a distinct quarantine-labelled ::error:: annotation ("quarantined
unparseable output — manual triage required") and re-exits 5 via the existing
exit "$EXIT_CODE" path (O1). The generic crash message is reserved for all
other non-{0,2,5} codes. The exit_code==2 fail step is untouched.

Red/green shell-harness: pre-change EXIT_CODE=5 emitted the generic "crashed"
message; post-change emits the quarantine label and still exits 5.
Delete the prose-scrape path (scrapeModels, sourceFiles,
GEMINI_MODEL_PATTERN, filterStableGeminiModels,
AIMOCK_GEMINI_PROVIDER_MODES) that caused incident 5. Each provider
block now fetches live /models, normalizes every id to its family via
normalizeModelFamily, subtracts includeFamilies ∪ excludeFamilies and
NON_MODEL_TOKENS, and surfaces the unclassified remainder as a
formatDriftReport-wrapped failing assertion so the collector routes it
(exit-2 auto-fix / exit-5 quarantine).

Adds a no-key regression suite exercising the real pipeline: incident-2
dated snapshots of known families produce zero delta; a prose
provider-mode token can never enter; a genuinely new family still fires.
…eModelFamily

Anthropic dates its model ids with an undelimited -YYYYMMDD suffix
(e.g. claude-3-5-sonnet-20241022) rather than the dashed -YYYY-MM-DD
form the shared core strips. Add an anthropic-only /-\d{8}$/ strip via
the existing provider hook, applied inside the same idempotent reduction
loop, so dated Claude ids collapse onto their family. openai/gemini keep
only the shared core so a non-date 8-digit tail is never over-stripped.
Re-add the Anthropic dated-id case B4.2 removed to the incident-2
injected-payload regression: claude-3-5-sonnet-20241022,
claude-3-7-sonnet-20250219, claude-3-5-haiku-20241022 now normalize
onto their included families and assert ZERO unclassified delta, guarding
the incident-2 class for Anthropic's contiguous -YYYYMMDD snapshots.
…ape regression suite (B4.3)

Extend model-registry.test.ts with the §6.1c cross-check (Part 2): a static
table of model ids referenced by aimock's DEFAULT_MODELS (server.ts), conformance
tests, and fixture-blocks tests, organised by provider. Every entry must resolve
via normalizeModelFamily to a family in includeFamilies (aimock mocks it) or
excludeFamilies (retired/non-text/preview). The gemini-interactions provider-mode
token must appear on NON_MODEL_TOKENS. A stray builder id or dropped allowlist
entry fails the unit test before it can cause a live crash in the drift job.

Delete models-scrape.drift.ts: its two tests exercised scrapeModels,
filterStableGeminiModels, and sourceFiles which were deleted in B4.2 (they
tested the now-gone scrape path). Both tests were already failing with
TypeError. grep -rn "models-scrape" src/ confirms no remaining importers.
Introduce `InfraError extends Error` with a first-class readonly `status:
number` field.  Update `assertOk` to throw `InfraError(msg, status)` and
`withInfraErrorTag` to propagate the numeric status on re-wrap, so downstream
classifiers (Slack alerts C5.2, preflight C5.3a) can key off
`status === 401 || 403 → stale-key` vs `429/5xx → infra-transient` without
any prose-message parsing.

Network errors that never reach assertOk have no HTTP status; they remain
plain Errors with status 0 on the InfraError wrapper.
…tReport

Upgrade summarizeDriftReport to:
- Compute a headline class (real-drift / stale-key / infra-transient /
  quarantine / test-infra-false-positive) from InfraError status, report
  entries severity, and quarantine[] presence. Priority: stale-key >
  infra-transient > real-drift > quarantine > test-infra-false-positive.
  Critical wins over quarantine (mirrors computeExitCode contract).
- Emit per-item detail: provider / id-or-path / builderFile / issue text.
- Render a distinct quarantine section for report.quarantine[] entries,
  with testName + rawLocation (file:line) + truncated message.
- Accept optional SummarizeOptions { infraErrorStatus?, exitCode? } for
  classification context; zero-opt callers and empty reports preserve the
  historical empty-string return.
- Export HeadlineClass and SummarizeOptions types for downstream use.

The drift_summary GITHUB_OUTPUT key is unchanged — callers write the full
return value verbatim; the workflow's DRIFT_SUMMARY env-var receives the
augmented mrkdwn string. Verified: test-drift.yml reads only
steps.summary.outputs.drift_summary (line 42) via writeGithubOutput, which
is invoked unchanged in main() with the summary as the sole value.

Golden-file snapshot tests cover all five classes (real-drift, stale-key,
infra-transient, quarantine, test-infra-false-positive), per-item id
preference over path, quarantine distinct-line format, and mixed
entries+quarantine. 10 new tests; all 4386 tests pass, tsc clean, lint clean.
Add a preflight step at the start of the `drift` job: a cheap GET /models
per provider (OpenAI/Anthropic/Gemini) before the 15-minute suite. A dead
or revoked key returns 401/403, which the step classifies as `stale-key`
and fails LOUD with a "rotate <PROVIDER>_API_KEY" message — never a silent
green skip — so a dead key surfaces up front instead of as a late generic
streaming failure.

Classification keys off the numeric InfraError.status (401/403), not the
prose message. Transient statuses (429/5xx/network) are not stale keys: the
preflight warns and continues, since the retry-wrapped collector handles
those.

The preflight program is written to the checkout root before running so its
relative provider import resolves against the repo (ESM relative specifiers
key off the module location, not cwd); cleaned up on exit.
…fy quarantine class

Promote the drift collector's exit_code from a step output to a
drift-job JOB-level output (alongside summary/runs) so the separate
notify job can read needs.drift.outputs.exit_code.

In the notify job, classify exit_code==5 (collector quarantined
unparseable output) as a distinct quarantine alert BEFORE the generic
DRIFT_RESULT==failure -> HTTP_DRIFT=true fallback. Without this, an
exit-5 quarantine (drift job concludes failure) was misreported as real
HTTP API drift (providers changed formats), sending readers chasing a
phantom format change instead of doing the manual triage a quarantine
needs. Exit 2 (real critical drift) and normal failures still route to
the HTTP-drift/real-drift path unchanged. M5 backstop per ironclad
spec 6.4.
Set `id` on every ParsedDiff produced by the collector so downstream
delta logic (D6.1) can key findings by provider+id:

- parseDriftBlock: `id = path` (stable per-item key from the offending
  field path; different paths → different delta keys)
- canary CLASS 3 (unknown-models): `id = <model-id>` (the value already
  in `diff.real`), so N distinct unknown model ids → N distinct delta keys
  rather than collapsing under a shared `path:"knownModels"` bucket
- canary CLASS 2 / noGA unknownIds spread: same `id = <model-id>` treatment

Additive only — no existing field removed; exit-code/quarantine logic
(computeExitCode, collectDriftEntries quarantine path) is untouched.

RED: 3 unknown model ids → all diffs had `id === undefined` → 1 collapsed key
GREEN: 3 unknown model ids → 3 distinct `id` values → 3 distinct delta keys
Adds scripts/drift-delta.ts: a pure, side-effect-free delta layer that
reduces a base (main) report and head (PR) report to block/advisory/fixed
keyed by provider+per-item-id.

Routing is by key presence ONLY — DriftClass is annotation and never
enters the block/advisory decision:
  - new-in-head (absent from base) -> block (diff-attributable)
  - present in both                -> advisory (environmental/world drift)
  - base-only                      -> fixed (informational)

isBaseReportReusable (O-2) guards cached-base reuse: non-empty entries +
known-good conclusion + same-UTC-day.

Includes the M-1 golden regression (#292): a real-drift/critical finding
new-in-head MUST block regardless of class (the old class-routed rule
would have greenlit it).
Add a new drift-live-pr job to test-drift.yml that runs the live drift
collector on both base (main) and head (the PR ref), producing two report
artifacts. Base reuses the most recent same-UTC-day well-formed main
scheduled report via isBaseReportReusable (D6.1), else runs a fresh live
base. Triggered on pull_request (NOT pull_request_target, O3) so fork PRs
run without secrets; path-filtered to drift scripts, drift tests, and drift
workflows. This is the skeleton only — the delta comparison / block-vs-
advisory step plugs in at the D6.3b SEAM marker.
…e-present

Plug the D6.3b delta step into the drift-live-pr job at the SEAM. The step
loads drift-report-base.json + drift-report-head.json and runs computeDelta
(scripts/drift-delta.ts) via a quoted-heredoc tsx invocation, matching the
preflight step's proven pattern.

Routing is by KEY PRESENCE only (#292 invariant), never by DriftClass:
- block[]    (new-in-head) → ::error:: annotation + exit 1 (required check fails)
- advisory[] (base-present) → ::notice:: neutral annotation + summary, exit 0
- fixed[]    → informational summary line only

Also documents the fork→maintainer-dry-run fallback in-job: fork PRs run
without secrets, so a maintainer runs the delta dry-run from a trusted
checkout and records the block/advisory conclusion.
…ndered binary)

Replace the \x00 byte separator in keyOf() with '::' — a printable separator
that cannot appear in a provider name or model id. The NUL caused git to classify
scripts/drift-delta.ts as binary (Bin diff), making the file unreadable in any
diff or review. Behavior-preserving: both base and head key through the same
keyOf(), so equality/presence comparisons are unchanged.
@pkg-pr-new

pkg-pr-new Bot commented Jul 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@297

commit: 8c7fd88

The drift-live-pr required check hard-failed on every drift-code PR: the
head-leg step ran drift-report-collector.ts directly and let the collector's
exit 2 ("critical drift found") propagate as a step failure, so the delta
gate — the step that actually decides block-vs-advisory by key presence — was
skipped and the check died for the wrong reason. A dead configured key also
went silently green because the PR leg had no key-freshness preflight.

Fix, scoped entirely to the workflow:

- Add a PR-scoped per-provider key-triage preflight. No key configured for a
  provider -> SKIP that provider's live leg with a neutral ::notice:: advisory
  (not a failure; the drift suite's own describe.skipIf already drops the
  tests). Key present but 401/403 -> loud ::error title=stale-key:: and block
  (a configured-but-dead key stays a real failure, per the InfraError.status
  contract). Transient (429/5xx/network) warns and continues. Records
  live_legs_ran for downstream steps.
- Base and head legs: capture the collector exit code and treat exit 2
  (drift-present) as non-fatal — the report file is the signal, the delta gate
  interprets it. Exit 5 (quarantine) and any other non-zero still fail the leg.
- When no provider key had a usable credential (fork PR / all keys absent),
  write empty well-formed base/head reports and neutral-pass the delta gate
  with a 'live drift leg skipped — no keys' advisory (maintainer-dry-run
  fallback applies).

Delta blocking behavior is unchanged when keys exist: a new-in-head critical
still fails the required check.
@jpr5
jpr5 force-pushed the drift-ironclad-impl branch 2 times, most recently from 99b0922 to 8c7fd88 Compare July 15, 2026 17:55
@jpr5
jpr5 marked this pull request as ready for review July 15, 2026 18:09
@jpr5
jpr5 merged commit 03756da into main Jul 15, 2026
28 of 29 checks passed
@jpr5
jpr5 deleted the drift-ironclad-impl branch July 15, 2026 18:09
jpr5 added a commit that referenced this pull request Jul 15, 2026
…s non-fatal (#298)

## The bug

The `drift-live-pr` job's **head leg** let the drift collector's exit
code 2 (critical drift found) propagate as a **step failure**. But
exit-2 is the *expected* "drift present" signal that must feed the delta
gate — it has to be **non-fatal** for the leg so the delta gate can
decide block-vs-advisory by key presence.

GitHub Actions runs `run:` blocks under `bash -e`. The head-leg step's
`set -uo pipefail` line does **not** clear that `-e`, and there was no
`set +e` guard around the collector call. So the instant the collector
returned 2, `-e` aborted the step at the `npx` line — before
`HEAD_EXIT=$?` was even captured — and the delta gate never ran.

On PR #297 the head leg logged `Exiting with code 2 (critical diffs
found)` then `##[error]Process completed with exit code 2`. The prior
fix (#297) only hardened the *base* leg (which already wrapped the
collector in `set +e`/`set -e`) and the no-keys degradation path; the
keys-present head-leg live run still propagated exit-2.

Makes drift-live-pr functional on drift-code PRs (was merged
non-functional in #297).

## The fix (scoped only to `.github/workflows/test-drift.yml`)

Both the base-leg and head-leg keys-present (`LIVE_LEGS_RAN=true`)
live-run steps now capture the collector's exit code with `set +e`/`set
-e` and treat it as **data**:

- **exit 0** → clean, continue.
- **exit 2** → drift present → **non-fatal**, continue (the
`drift-report-{base,head}.json` report feeds the delta gate).
- **exit 5** (quarantine) → genuine fault → **fail the leg** (explicit
classification).
- **any other non-{0,2}** → genuine collector fault → **fail the leg**.

Report filenames/paths are unchanged (`drift-report-base.json` /
`drift-report-head.json`), so the delta gate contract is untouched. The
head leg was the actual #297 bug (it lacked the `set +e` guard); the
base leg already tolerated exit-2, and gains only the explicit exit-5
classification for symmetry.

### Diff hunk

```diff
@@ head leg ("Head drift report — run live on PR ref") @@
+          # ... capture exit as DATA (see comment) ...
+          set +e
           npx tsx scripts/drift-report-collector.ts --out drift-report-head.json
           HEAD_EXIT=$?
+          set -e
+          if [ "$HEAD_EXIT" -eq 5 ]; then
+            echo "::error::Head collector quarantined unparseable output (exit 5) — manual triage required"
+            exit "$HEAD_EXIT"
+          fi
           if [ "$HEAD_EXIT" -ne 0 ] && [ "$HEAD_EXIT" -ne 2 ]; then
             echo "::error::Head collector faulted (exit $HEAD_EXIT) — not a drift signal"
             exit "$HEAD_EXIT"
           fi
           echo "head: collector exit $HEAD_EXIT (0 clean / 2 drift-present — both non-fatal here)"
```

(The base leg gains the same explicit `exit 5` classification block
ahead of its existing `-ne 0 && -ne 2` fault check.)

## Red-green proof (keys-present live-run path)

The harness programmatically extracts the **real** base-/head-leg `run:`
bash from the YAML (python YAML parse — not hand-reconstructed), then
drives it under GitHub's exact default shell (`bash --noprofile --norc
-e -o pipefail`) with a fake `npx` collector on PATH that writes the
expected `drift-report-{base,head}.json` and returns a configurable exit
code. `LIVE_LEGS_RAN=true` forces the keys-present branch.

### RED — current `main` YAML, head leg, fake collector exit 2
(reproduces #297)

```
=== RED: HEAD leg, current main YAML, fake collector exit 2 ===
===== STEP STDOUT =====
fake-collector: writing report, exiting 2 (critical diffs found)
===== STEP STDERR =====
===== STEP EXIT CODE: 2 =====
REPORT PRESENT: drift-report-head.json (103 bytes)
HARNESS RC (RED): 2
```

The step FAILS with exit 2 (the `head: collector exit ...` line never
prints — `-e` killed the step at the `npx` line). This is the #297
failure.

### GREEN — fixed YAML, head leg

```
=== HEAD leg, fake collector exit 2 ===
fake-collector: writing report, exiting 2 (critical diffs found)
head: collector exit 2 (0 clean / 2 drift-present — both non-fatal here)
===== STEP EXIT CODE: 0 =====
REPORT PRESENT: drift-report-head.json (103 bytes)
HARNESS RC (head exit 2): 0

=== HEAD leg, fake collector exit 5 ===
fake-collector: writing report, exiting 5 (critical diffs found)
::error::Head collector quarantined unparseable output (exit 5) — manual triage required
===== STEP EXIT CODE: 5 =====
HARNESS RC (head exit 5): 5

=== HEAD leg, fake collector exit 0 ===
fake-collector: writing report, exiting 0 (critical diffs found)
head: collector exit 0 (0 clean / 2 drift-present — both non-fatal here)
===== STEP EXIT CODE: 0 =====
HARNESS RC (head exit 0): 0
```

- exit 2 → step **succeeds** (rc captured, non-fatal), report present,
control reaches the post-collector line (delta gate runs).
- exit 5 → step **still fails** the leg (quarantine).
- exit 0 → clean.

### GREEN — fixed YAML, base leg (same three)

```
=== BASE leg, fake collector exit 2 ===
base: no reusable same-UTC-day main report — running fresh live base
fake-collector(base): writing report, exiting 2
===== STEP EXIT CODE: 0 =====
REPORT PRESENT: drift-report-base.json (103 bytes)

=== BASE leg, fake collector exit 5 ===
fake-collector(base): writing report, exiting 5
::error::Base collector quarantined unparseable output (exit 5) — manual triage required
===== STEP EXIT CODE: 5 =====

=== BASE leg, fake collector exit 0 ===
fake-collector(base): writing report, exiting 0
===== STEP EXIT CODE: 0 =====
```

## Quality

- `actionlint` clean on my changed steps. (The one reported SC2086 is at
the pre-existing notify-job "Check previous run status" step — not part
of this change.)
- `pnpm run format:check` — clean.
- `pnpm run lint` — clean.
- `git status` — only `.github/workflows/test-drift.yml` modified.
Harness is a scratch tool under `/tmp`, not committed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

1 participant