diff --git a/apps/website/content/blog/2026-08-29-what-fixture-replay-cant-catch.mdx b/apps/website/content/blog/2026-08-29-what-fixture-replay-cant-catch.mdx new file mode 100644 index 000000000..52cdd2b96 --- /dev/null +++ b/apps/website/content/blog/2026-08-29-what-fixture-replay-cant-catch.mdx @@ -0,0 +1,165 @@ +--- +title: "What Fixture Replay Can't Catch" +description: 'Our agent e2e suite replaces the model and keeps everything else real. That buys determinism by deleting time — and one bug class disappears with it.' +date: 2026-08-29 +tags: [testing, langgraph, agents, streaming, angular] +author: brian +featured: false +draft: false +--- + +Every deterministic test harness buys its determinism by deleting a dimension. +Ours deletes time, deliberately, and the reason is written in the source. + +So the interesting question about a harness isn't whether it's green. +It's which dimension you deleted, because that's the list of bugs it can't report. + +## Where do you put the mock? + +At the model provider, not the app. + +Our end-to-end suite starts a mock OpenAI server, then spawns the real agent server with its base URL pointed at that mock — `langgraph dev` for the LangGraph apps, uvicorn for the AG-UI ones: + +```typescript +const aimock = await startAimock({ mode: 'replay', fixturePath: opts.fixturesDir }); + +spawn('uv', ['run', 'langgraph', 'dev', '--port', String(langgraphPort)], { + env: { + ...process.env, + OPENAI_BASE_URL: aimock.baseUrl, // the only thing that isn't real + OPENAI_API_KEY: 'test-not-used', + }, +}); +``` + +Everything above that line is the real thing. +A real Angular app, the real streaming transport, a real Python server, real graph nodes with their edges and conditional routing. +The model is the only stand-in. + +Let's take the alternative. +A test that mocks the agent at the app boundary proves your component renders what you handed it. It cannot tell you that your graph's conditional edge routes correctly, that your transport merges deltas in the right order, or that a tool call round-trips. +Push the seam out to the provider and all of that is under test, because none of it was replaced. + +Replacing the model — and only the model — is also what makes it cheap enough to run everywhere: no API spend, no rate limits, no coin flips. +We have 50 fixture files holding 129 entries across 34 apps — 32 cockpit capabilities and two example apps — all on the same harness. + +This is the outer tier. For in-process fakes at the unit level, the [testing guide](/docs/langgraph/guides/testing) covers `provideFakeAgent()`, `mockLangGraphAgent()`, and `MockAgentTransport`, which are a different tool for a different job. + +## What does a fixture match on? + +The shape of the request — and the order you list the entries decides which one wins. + +Each entry carries a `match` block. The obvious discriminator is the user message, but there are richer ones: a parent LLM's first call and its continuation after a tool round carry the same user message. Something has to tell them apart. + +That something is `hasToolResult`, and matching is first-match-wins: + +```json +{ + "fixtures": [ + { "match": { "userMessage": "book a flight", "hasToolResult": true }, "response": "..." }, + { "match": { "userMessage": "book a flight" }, "response": "..." } + ] +} +``` + +Swap those two entries and the run never terminates. + +The continuation arrives carrying the same user message, matches the looser entry first, and gets handed the response that asks for the tool call again. +The model calls the tool. The result comes back. It matches the looser entry again. +Nothing errors. The assistant simply never finalizes. + +For me that's the sharpest thing about fixture files: they're data, so they look inert, but the ordering is executable. + +## What did we trade away? + +The streaming, on purpose. + +The mock is constructed with a chunk size large enough that every response arrives in one or two server-sent events. Here's the whole note that sits above it: + +```typescript +// Use a large chunkSize so each response arrives in 1-2 SSE deltas. This +// intentionally turns off the partial-markdown streaming path for harness +// tests: structural assertions (code fence, list) measure the FINAL rendered +// DOM, not the progressive render. With aggressive default chunking, the +// partial-markdown parser sometimes can't recover a triple-backtick fence +// that gets split mid-token, and the final state ends up as inline +// instead of
. Streaming-progressive behavior is covered by the
+// Phase 1 unit-variance tables; the e2e harness is for final-state
+// invariants and cross-stack integration.
+const mock = new LLMock({ port: 0, chunkSize: 4096 });
+```
+
+A real rendering bug, but a *streaming* one, and it was making structural assertions flaky for reasons that had nothing to do with what they asserted. So the timing went away and the property moved down a tier.
+
+I think that's the right trade, and the reason isn't that the flakiness went away — it's that the property didn't.
+
+The tempting alternative is to replay the recorded chunk boundaries instead of re-chunking, so you get the timing back for free.
+That doesn't buy what it looks like it buys. Faithful boundaries make the fence failure *deterministic* rather than absent — the parser bug is still there, and now every structural assertion in the suite fails for a reason none of them are about.
+
+Now the part that's easy to get wrong without going looking, and it's the useful half.
+
+That 4096 is a _default_, not a law. A second harness serves our two example apps, and its version of that comment says so outright — ordinary fixtures get the big chunk size, and *targeted streaming regressions opt into smaller per-fixture chunks*. Those fixtures set chunk sizes of three, four, six, twenty-three, thirty-six, with latencies from 25 to 750 milliseconds. There are e2e tests over there that sample the mid-stream DOM while it renders.
+
+So "we deleted time" is too tidy. What we did was delete it by default and buy it back per fixture, in the places somebody decided it was worth the cost.
+
+Which turns the question into a better one. Not *what did the harness give up*, but *which tier opted back in* — because the tier that didn't is the one flying blind.
+
+## What does that hide?
+
+Bugs that exist only while the stream is open and fix themselves before it closes.
+
+We shipped one recently and fixed it, and where it lived is the whole argument: a cockpit capability, on the harness with no per-fixture opt-in.
+
+A demo runs a child graph as a plain node — the shape [the subgraphs post](/blog/langgraph-subgraphs-when-to-split) ends on. A plain subgraph node's events aren't tagged as a delegated subagent, so the bridge merges the child's tokens into the transcript as they arrive.
+
+The child in that graph produces an internal research brief, meant for the parent to write its answer from. Without the option that whitelists which nodes count as transcript, that brief renders as its own chat bubble, and the message list transiently reaches three.
+
+Then the run settles. The parent publishes its authoritative state, the transcript is rebuilt from it, and the extra bubble vanishes.
+
+Read that sequence again from a test's point of view.
+The end state is correct. Two messages, in the right order.
+Assert on the finished DOM and it passes — not by luck.
+
+Confirming the fix meant driving it against a live model and sampling the DOM on a tight interval for the length of a full run, watching that the message count never crossed two. Not something the suite does, and not something it could tell us.
+
+Let's generalize, because this isn't specific to us.
+Any assertion that runs after an `await` sees a settled system. A self-correcting bug is precisely one that settles.
+So the class of defects a final-state suite cannot see isn't random — it's exactly the ones that repair themselves.
+
+## What runs alongside it?
+
+A live pass for what replay structurally can't see, and a weekly drift run for the model itself.
+
+The live pass is unglamorous: for anything whose failure mode is mid-stream, drive it against a real model in a real browser before it ships. That's how the bubble above got caught. There's no clever tooling in it — the point is just that the deterministic suite was never going to be the thing that found it.
+
+Drift is the other half, and it got built properly on the way to this post. Our first design re-recorded fixtures and compared byte size — which detects that a response changed *size* while saying nothing about whether it changed *meaning*. The same trade again, one layer up. A model that starts returning something equally long and completely different sails straight through a size check.
+
+So we threw the metric away and kept the thing we already trusted.
+
+The assertions are the drift check.
+
+The drift run takes a tagged subset of the same e2e suite — contract assertions only: a reply renders, the research dispatch surfaces a subagent card, the interrupt panel appears — and points it at the live provider through the mock's record-proxy. No fixtures judged, no thresholds invented.
+If today's model stops calling the research tool, or the graph's prompts stop eliciting the interrupt, a spec we already believe in goes red and a weekly job opens an issue. Meaning drift is caught by construction.
+
+One rule makes the subset work: a tagged assertion may depend on structure or on the prompt's own terms — an element exists, a reply to "say hi" matches `/hi/i` — never on the content of a canned response. A spec that expects the fixture's exact words fails against a live model whether or not anything drifted, so it stays in replay where it belongs.
+
+The first run flagged drift that wasn't there.
+The diagnostic differ reported that both tool-calling responses had "drifted" to plain text — while the specs proving those tools fired were green. The recorder had saved empty content because it couldn't parse tool-call deltas out of the stream, warning "fixture may be incomplete." The check's first finding was a blind spot in its own instrument; it now reports that case in its own category instead of as drift.
+It's run clean against the live model since.
+
+## Conclusion
+
+Push your seam as far out as you can afford. The further out it goes, the more of your stack is under test rather than simulated, and provider base URL is far out for how little it costs.
+
+Then write down which dimension you deleted to make it deterministic, in the file where you deleted it. Not in a wiki. Six months later that comment is the difference between "the suite is green" and "the suite is green, and here is what green does not cover."
+
+Ours was written down, which is the only reason it could be checked at all.
+Checking it surfaced two things: the deletion was a default two of our apps had already bought back, and our first drift design was measuring the wrong thing.
+
+For us the list is short and specific: anything whose failure mode is mid-stream on a capability that never opted back into real chunking, and the model itself moving under the fixtures.
+Both are answered by pointing what you already trust at the real thing — the tagged specs at the live provider on a schedule, a live browser at anything mid-stream before it ships.
+Widening the drift net is tagging more specs, plus one chore: porting record mode to the harness the other thirty-two apps share. Widening the stream check is opting more fixtures into real chunking. Neither is designing anything new.
+
+The [testing guide](/docs/langgraph/guides/testing) has the in-process tier, and [the subgraphs post](/blog/langgraph-subgraphs-when-to-split) has the bug that started this.
+
+If your agent suite is green today, I'd like to know what you think it can't see.
diff --git a/docs/superpowers/plans/2026-08-28-fixture-replay-post.md b/docs/superpowers/plans/2026-08-28-fixture-replay-post.md
new file mode 100644
index 000000000..4222d7488
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-fixture-replay-post.md
@@ -0,0 +1,352 @@
+# "What Fixture Replay Can't Catch" Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship blog post #12 — an essay arguing that a deterministic agent-test harness buys determinism by deleting time, which makes mid-stream self-correcting bugs structurally invisible to a green suite.
+
+**Architecture:** One new MDX file under `apps/website/content/blog/`. No code changes, no new components. The website renders blog posts from frontmatter + markdown; the filename encodes date and slug.
+
+**Tech Stack:** MDX, Next.js (apps/website), gray-matter frontmatter, Shiki fences.
+
+**Source of truth:** `docs/superpowers/specs/2026-08-28-fixture-replay-post-design.md`. Read it before Task 1.
+
+---
+
+## File Structure
+
+- **Create:** `apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx` — the entire deliverable.
+
+The filename must match `YYYY-MM-DD-.mdx`; `apps/website/src/lib/blog.ts` parses date and slug from it and throws if `title`, `description`, `date`, or `author` are missing from frontmatter.
+
+---
+
+### Task 1: Frontmatter and skeleton
+
+**Files:**
+- Create: `apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx`
+
+- [ ] **Step 1: Create the file with exactly this frontmatter**
+
+```mdx
+---
+title: "What Fixture Replay Can't Catch"
+description: 'Our agent e2e suite replaces the model and keeps everything else real. That buys determinism by deleting time — and one bug class disappears with it.'
+date: 2026-08-28
+tags: [testing, langgraph, agents, streaming, angular]
+author: brian
+featured: false
+draft: false
+---
+```
+
+Rules: `author` must be `brian` (the only value in use). `description` must stay under 180 characters — docs meta descriptions truncate there. Count it before moving on.
+
+- [ ] **Step 2: Add the H2 skeleton, in this order**
+
+```
+## Where do you put the mock?
+## What does a fixture match on?
+## What did we trade away?
+## What does that hide?
+## What runs alongside it?
+## Conclusion
+```
+
+Every H2 is a question except the last, and each must be answered in its first line.
+
+- [ ] **Step 3: Verify frontmatter parses**
+
+Run:
+```bash
+cd apps/website && npx vitest run --config vite.config.mts src/lib/blog.spec.ts
+```
+Expected: PASS. A missing required field throws at read time, so a failure here means frontmatter is malformed.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx
+git commit -m "feat(website): scaffold the fixture-replay post"
+```
+
+---
+
+### Task 2: Opening and the seam
+
+**Files:**
+- Modify: `apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx`
+
+- [ ] **Step 1: Write the opening, before the first H2**
+
+Two to four lines. State the thesis outright: a deterministic harness buys determinism by deleting a dimension, ours deletes time, and the useful question about any harness is which dimension it deleted.
+
+Do not announce the post's structure ("first we'll look at..."). Neither sibling post does, and a reviewer flagged it on the subgraphs post.
+
+- [ ] **Step 2: Write "Where do you put the mock?"**
+
+Answer in the first line: at the model provider, not the app.
+
+Content, all verified — do not restate line numbers in prose:
+- `libs/e2e-harness/src/global-setup-factory.ts` spawns a real `langgraph dev` subprocess with `OPENAI_BASE_URL` pointed at the mock server and `OPENAI_API_KEY: 'test-not-used'`.
+- Everything above that seam is the real thing: real Angular app, real transport, real LangGraph server, real Python graph nodes. Only the model is replaced.
+- Scale: 50 fixture files, 129 fixture entries, 34 apps. **Say "34 apps" or "32 cockpit capabilities and 2 example apps" — never "34 capabilities."** Two of them are examples.
+
+Include one fenced `typescript` block showing the seam. Use exactly this, which is faithful to the source:
+
+```typescript
+const aimock = await startAimock({ mode: 'replay', fixturePath: opts.fixturesDir });
+
+spawn('uv', ['run', 'langgraph', 'dev', '--port', String(langgraphPort)], {
+  env: {
+    ...process.env,
+    OPENAI_BASE_URL: aimock.baseUrl,   // the only thing that isn't real
+    OPENAI_API_KEY: 'test-not-used',
+  },
+});
+```
+
+- [ ] **Step 2b: Link the docs tier rather than restating it**
+
+One sentence pointing at [`/docs/langgraph/guides/testing`](/docs/langgraph/guides/testing) for the in-process fakes (`provideFakeAgent()`, `mockLangGraphAgent()`, `MockAgentTransport`). Do not explain those APIs — that page owns them, and restating them risks co-ranking.
+
+- [ ] **Step 3: Check verbatim overlap against that docs page**
+
+Run:
+```bash
+cd apps/website && python3 - <<'EOF'
+import re
+def prose(p):
+    t=open(p).read(); t=re.sub(r'```.*?```','',t,flags=re.S)
+    t=re.sub(r'^---.*?^---','',t,flags=re.S|re.M); t=re.sub(r'<[^>]+>','',t)
+    return re.findall(r"[a-z']+",t.lower())
+post=prose('content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx')
+w=prose('content/docs/langgraph/guides/testing.mdx')
+doc=set(tuple(w[i:i+8]) for i in range(len(w)-7))
+hits=[' '.join(post[i:i+8]) for i in range(len(post)-7) if tuple(post[i:i+8]) in doc]
+print("8-gram overlap:",len(hits),hits[:3])
+EOF
+```
+Expected: `8-gram overlap: 0`. Any hit means a phrase was lifted — rewrite it in your own words and re-run.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx
+git commit -m "feat(website): opening and the seam"
+```
+
+---
+
+### Task 3: Fixture matching and the ordering trap
+
+**Files:**
+- Modify: `apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx`
+
+- [ ] **Step 1: Write "What does a fixture match on?"**
+
+Answer in the first line: on the shape of the request, and the order you list them decides which one wins.
+
+Content, verified against `cockpit/langgraph/client-tools/angular/e2e/fixtures/client-tools.json`:
+- A fixture entry carries a `match` block. Discriminators include `userMessage`, plus richer ones like `toolName` and `hasToolResult` that distinguish a first call from the continuation after a tool round.
+- Matching is **first-match-wins**.
+- That file holds 7 entries in pairs, and in every pair the `hasToolResult: true` entry is listed **before** its plain `userMessage` twin.
+- Reverse a pair and the post-tool continuation re-matches the original tool call, so the model is told to call the tool again — an infinite loop where the assistant never finalizes.
+
+- [ ] **Step 2: Add one fenced `json` block**
+
+Show the ordering, abbreviated but structurally faithful:
+
+```json
+{
+  "fixtures": [
+    { "match": { "userMessage": "book a flight", "hasToolResult": true }, "response": "..." },
+    { "match": { "userMessage": "book a flight" }, "response": "..." }
+  ]
+}
+```
+
+Then one line: swap those two and the run never terminates.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx
+git commit -m "feat(website): fixture matching and the ordering trap"
+```
+
+---
+
+### Task 4: The trade, and what it hides
+
+**Files:**
+- Modify: `apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx`
+
+This is the heart of the post. Give it the most care.
+
+- [ ] **Step 1: Write "What did we trade away?"**
+
+Answer in the first line: the streaming, on purpose.
+
+Content, verified in `libs/e2e-harness/src/aimock-runner.ts`:
+- The mock is constructed with `chunkSize: 4096`, which is large enough that each response arrives in one or two SSE deltas.
+- The reason is written in a comment above it: structural assertions (a code fence, a list) are meant to measure the **final** rendered DOM, not the progressive render. With default chunking the partial-markdown parser sometimes cannot recover a triple-backtick fence that gets split mid-token, and the final state degrades to an inline `` instead of a `
`.
+- Progressive behavior is covered by unit-level variance tables instead.
+
+Quote the comment in a fenced `typescript` block rather than paraphrasing it — the essay's method is "we wrote it down," so show the writing:
+
+```typescript
+// Use a large chunkSize so each response arrives in 1-2 SSE deltas. This
+// intentionally turns off the partial-markdown streaming path for harness
+// tests: structural assertions (code fence, list) measure the FINAL rendered
+// DOM, not the progressive render.
+const mock = new LLMock({ port: 0, chunkSize: 4096 });
+```
+
+Flag the opinion: this is a defensible trade, and the point is that it was made deliberately and recorded, not that it was wrong.
+
+- [ ] **Step 2: Write "What does that hide?"**
+
+Answer in the first line: bugs that exist only while the stream is open and fix themselves before it closes.
+
+Content — this is the worked example, measured on 2026-08-27:
+- The `cockpit/langgraph/subgraphs` demo runs a child graph as a plain node. Its namespace is not a `tools:` subagent namespace, so the bridge merges the child's tokens into the transcript as they arrive.
+- Without `transcriptNodeNames: ['answer']`, the child's internal research brief renders as its own chat bubble, and the message list transiently reaches three.
+- The parent's final `values` event then rewrites the message list from authoritative graph state, and the stray bubble disappears.
+- So the end state is correct. Assert on the finished DOM and the test passes. The defect is real and invisible to it.
+- Confirming the fix took sampling the DOM every 60ms against a live model across a full streaming run: the message count never exceeded two across 56 samples.
+
+Generalize in one line: any assertion that runs after `await` sees a settled system, and a self-correcting bug is exactly the kind that settles.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx
+git commit -m "feat(website): the determinism trade and the bug class it hides"
+```
+
+---
+
+### Task 5: What runs alongside, and the close
+
+**Files:**
+- Modify: `apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx`
+
+- [ ] **Step 1: Write "What runs alongside it?"**
+
+Answer in the first line: a live pass for the things replay structurally cannot see, and a drift check for the fixtures themselves.
+
+Two pieces of content:
+
+1. **The live gate.** Replay cannot see streaming re-materialization, so anything whose failure mode is mid-stream needs driving against a real model before it ships. Keep this short and concrete; do not oversell it as a formal system.
+
+2. **Drift.** `examples/chat/angular/e2e/scripts/drift.ts` re-records each committed fixture against the live provider and compares it to the committed copy, flagging any whose size diverges by more than twenty percent; the workflow opens an issue when it trips.
+
+**CRITICAL — do not claim a cadence.** Do not write "nightly", "weekly", "on a schedule", or "in CI on every run" about drift detection. Describe the mechanism only. The cron is currently not enabled. Mechanism-only phrasing is accurate now and stays accurate after it is enabled.
+
+Then be honest about what that check can prove, because it is another instance of the thesis: comparing byte size detects that a response changed *size*, not that it changed *meaning*. A model that returns something equally long and completely different passes.
+
+- [ ] **Step 2: Write the Conclusion**
+
+Restate the heuristic without repeating earlier sentences verbatim: pick your seam as far out as you can afford, then write down which dimension you deleted, because that is the list of bugs your suite cannot report.
+
+End with an invitation, matching the siblings — the json-render post closes by asking where it lands for the reader. Do not write a marketing CTA.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx
+git commit -m "feat(website): the live gate, drift, and the close"
+```
+
+---
+
+### Task 6: Validate
+
+**Files:**
+- Modify: `apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx` (only if a check fails)
+
+- [ ] **Step 1: Check the mechanical constraints**
+
+Run:
+```bash
+cd apps/website && python3 - <<'EOF'
+import re
+p='content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx'
+s=open(p).read(); body=re.sub(r'^---.*?^---','',s,flags=re.S|re.M)
+prose=re.sub(r'```.*?```','',body,flags=re.S)
+d=re.search(r"^description: '(.*)'$",s,re.M) or re.search(r'^description: "(.*)"$',s,re.M)
+print("description chars:",len(d.group(1)))
+print("words:",len(prose.split()))
+print("fences:",re.findall(r'```(\w+)',s))
+print("emoji present:",bool(re.search(r'[\U0001F300-\U0001FAFF]',s)))
+for bad in ['nightly','weekly','on a schedule','every night']:
+    if bad in prose.lower(): print("!! CADENCE CLAIM:",bad)
+EOF
+```
+Expected: description under 180; words roughly 1200-1500; fences are `typescript`, `json`, `typescript`; no emoji; no cadence claim.
+
+- [ ] **Step 2: Check overlap against the testing docs page**
+
+Re-run the overlap script from Task 2 Step 3. Expected: `8-gram overlap: 0`.
+
+- [ ] **Step 3: Render it**
+
+Run:
+```bash
+cd apps/website && npx next dev -p 3111
+```
+Then in a second shell:
+```bash
+curl -s -o /tmp/p.html -w '%{http_code}\n' http://localhost:3111/blog/what-fixture-replay-cant-catch
+grep -o '[^<]*' /tmp/p.html
+grep -c 'data-language' /tmp/p.html
+curl -s http://localhost:3111/blog | grep -c 'what-fixture-replay-cant-catch'
+```
+Expected: `200`; a `` containing the post title; a non-zero `data-language` count (fences highlighted); `1` for the listing.
+
+Note: `next dev` rewrites `apps/website/next-env.d.ts`. Run `git checkout apps/website/next-env.d.ts` before committing.
+
+- [ ] **Step 4: Run the website suite**
+
+Run:
+```bash
+cd apps/website && npx vitest run --config vite.config.mts
+```
+Expected: **10 failures, 5 files** — and confirm they are the same ones present on `main`: `thanks/page.spec.tsx` (3), `PostCard.spec.tsx`, `Differentiator.spec.tsx`, plus `api/ingest/route.spec.ts` and `lib/analytics/server.spec.ts` failing to resolve `posthog-node`.
+
+These are pre-existing and unrelated. Do not fix them in this PR. If the count differs from 10, this post broke something — investigate before continuing.
+
+`nx test website` does **not** work. Use the vitest command above.
+
+- [ ] **Step 5: Commit any fixes**
+
+```bash
+git add apps/website/content/blog/2026-08-28-what-fixture-replay-cant-catch.mdx
+git commit -m "fix(website): validation fixes for the fixture-replay post"
+```
+
+---
+
+## Hard prohibitions
+
+Carried from the spec. Violating any of these fails the task:
+
+- **No invented first-person anecdotes.** Brian's instruction: "don't make up stories." Every concrete claim traces to a file in this repo.
+- **No cadence claim about drift detection.** Mechanism only.
+- **Never write "34 capabilities."** It is 34 apps: 32 cockpit capabilities plus 2 example apps.
+- **No claim that the suite catches more than it does.** The thesis is the opposite.
+- No emoji, no hype, no marketing CTA, no "Introduction" heading, no licensing callout.
+- No line numbers in prose.
+- Do not restate the testing guide's in-process API surface; link it.
+- If you name a public API member, verify it exists in the published package, not just in `main` — releases fire only on a pushed tag and `main` runs ahead of npm.
+
+## Voice
+
+`docs/gtm/voice.md`, with the 2026 technical override. Register references: `2026-08-26-what-inject-agent-returns.mdx`, `2026-08-27-json-render-vs-a2ui-choosing.mdx`, `2026-08-27-langgraph-subgraphs-when-to-split.mdx`.
+
+- H2 as a question, answered in its first line.
+- `Let's` transitions — the siblings use about four each. Do not substitute a demonstrative tic (`That's`, `Here's`); a reviewer counted 13 `That's` in the subgraphs draft and flagged it.
+- Paragraphs of one to three lines. Put each sentence on its own source line, as the siblings do.
+- Flag opinions: "For me", "I think".
+- Italics for emphasis, never bold, in body prose.
+- Contractions throughout.
diff --git a/docs/superpowers/specs/2026-08-28-fixture-replay-post-design.md b/docs/superpowers/specs/2026-08-28-fixture-replay-post-design.md
new file mode 100644
index 000000000..87716cd09
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-28-fixture-replay-post-design.md
@@ -0,0 +1,80 @@
+# Design: "What Fixture Replay Can't Catch" (blog post #12)
+
+**Date:** 2026-08-28
+**Sequence:** post #12 of the GSC-driven blog sequence. Posts #11, #9, #1 shipped.
+**Status:** approved angle, pending spec review.
+
+## Why this post is different from the others
+
+It is the only post in the sequence with **no search evidence** behind it. #11, #9, and #1 were each selected against a Search Console pull. This one is a bet on being genuinely differentiated and shareable rather than on capturing existing demand.
+
+That raises the bar: if the material turns out thin, say so rather than padding. It did not turn out thin — see Verified facts.
+
+## Thesis
+
+A deterministic test harness buys determinism by deleting a dimension. Ours deletes **time** — deliberately, with the reason written in the source — and that makes one specific bug class structurally invisible to a green suite.
+
+The post is not "how to mock your LLM." That post exists everywhere. The post is "here is what my passing suite cannot see, and why I chose that trade anyway."
+
+## Co-ranking check
+
+`apps/website/content/docs/langgraph/guides/testing` covers the **in-process** tier: `provideFakeAgent()`, `mockLangGraphAgent()`, `MockAgentTransport`. Its only "fixture" occurrences are Angular `TestBed` / `ComponentFixture` (lines 91-101). It says nothing about aimock or fixture replay.
+
+So there is no co-ranking risk. The post covers the **out-of-process** harness, a different layer, and should link the guide for the unit tier rather than restate it.
+
+## Structure
+
+Target ~1,300 words. The subgraphs post ran ~1,900 and a reviewer measured that as roughly twice the two shipped siblings; this one stays tighter.
+
+1. **Opening.** Thesis in two lines. Determinism has a price; the useful question is which one you paid.
+2. **"Where do you put the mock?"** The seam is one environment variable. Everything above it runs for real.
+3. **"What does a fixture match on?"** Matching semantics, then the ordering constraint that loops forever.
+4. **"What did we trade away?"** `chunkSize: 4096`, quoted from source. Replay is near-atomic by design.
+5. **"The bug class that hides there."** Mid-stream and self-correcting. The subgraph transcript leak as the worked example.
+6. **"What runs alongside it."** The live gate, and how drift is detected.
+7. **Close.** Invitation, matching the siblings.
+
+## Verified facts
+
+Every line below was read directly in this repo on 2026-08-28.
+
+**The seam.**
+`libs/e2e-harness/src/global-setup-factory.ts:90` spawns a real `langgraph dev` with `OPENAI_BASE_URL: aimock.baseUrl` and `OPENAI_API_KEY: 'test-not-used'`. Real Angular app, real transport, real LangGraph server, real Python graph. Only the model provider is replaced. `ag-ui-global-setup-factory.ts:89` does the same for the AG-UI stack.
+
+**Scale.** 50 fixture files holding 129 total fixture entries, across 34 distinct apps — 32 cockpit capabilities plus 2 example apps (counted by parsing every `**/e2e/fixtures/*.json`). Say "apps" or "capabilities and example apps" in prose, not "capabilities" alone: 2 of the 34 are examples, not cockpit caps.
+
+**The determinism trade.**
+`libs/e2e-harness/src/aimock-runner.ts:59` constructs `new LLMock({ port: 0, chunkSize: 4096 })`. The comment at :50-58 states the rationale: a large chunk size makes each response arrive in 1-2 SSE deltas, so structural assertions measure the FINAL rendered DOM rather than the progressive render; with default chunking the partial-markdown parser sometimes cannot recover a triple-backtick fence split mid-token, and the final state degrades to inline `<code>`. Progressive behavior is covered by unit-variance tables instead.
+
+This is the load-bearing fact of the post: replay is near-atomic **on purpose**, and the reason is written down.
+
+**The ordering constraint.**
+`cockpit/langgraph/client-tools/angular/e2e/fixtures/client-tools.json` holds 7 entries. Every `match` block carrying `hasToolResult: true` precedes its plain `userMessage` twin (indices 0<1, 2<3, 5<6). Matching is first-match-wins, so reversing a pair makes the post-tool continuation re-match the original tool call and loop forever — the assistant never finalizes.
+
+**The bug class replay cannot catch.**
+Mid-stream defects with a clean end state. Worked example, measured this week: `cockpit/langgraph/subgraphs`. Without `transcriptNodeNames: ['answer']`, the child graph's internal brief renders as its own chat bubble and the message list transiently reaches 3 before the parent's authoritative `values` event collapses it back to 2 (`cockpit/langgraph/subgraphs/angular/src/app/app.config.ts:20`). A final-state assertion passes. Confirmed live with a 60ms DOM sampler against a real model: with the option set, `chat-message` count never exceeded 2 across 56 samples spanning a full stream.
+
+**Drift detection — describe the mechanism, not a cadence.**
+`examples/chat/angular/e2e/scripts/drift.ts` re-records each committed fixture against the live provider and compares **byte length**, flagging any fixture whose size diverges by more than 20% (`THRESHOLD_PCT = 0.2`). `.github/workflows/aimock-drift.yml` runs it and opens an issue on failure.
+
+The workflow is currently `workflow_dispatch` only; its cron is deliberately omitted because the committed fixtures are handwritten seeds rather than recordings. **Brian intends to fix this.** The post therefore describes how drift is detected — the mechanism — and makes no claim about a schedule. That phrasing is accurate today and remains accurate once the cron is enabled, so the post will not need revisiting either way.
+
+Worth saying plainly in the post: a byte-size comparison detects that a response changed *size*, not that it changed *meaning*. That is another instance of the thesis, not a contradiction of it.
+
+## Hard prohibitions
+
+- No invented first-person anecdotes. Brian: "don't make up stories."
+- No claim that the suite catches more than it does. The whole point is the opposite.
+- No cadence claim about drift detection.
+- No emoji, no hype, no marketing CTA, no "Introduction" heading, no licensing callout.
+- No line numbers in prose.
+- Do not restate the testing guide's in-process API surface; link it.
+- Verify any named public API against the published tarball, not just source — main routinely runs ahead of npm.
+
+## Voice
+
+`docs/gtm/voice.md` with the 2026 technical override. Register references: the two shipped siblings and the subgraphs post. H2-as-question answered in its first line, `Let's` transitions, 1-3 line paragraphs, opinions flagged ("For me", "I think"), italics-only emphasis, contractions kept.
+
+## Follow-up this post creates
+
+Enable the `aimock-drift.yml` cron once fixtures are recorded rather than handwritten. Tracked separately; the post does not depend on it.