Skip to content
Back to skills

Event Contract Scan

BSecurity

Event contracts: schema compatibility, tolerant readers, naming, event-store appends, projections — read-only.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 20, 2026
ai-agentspythonrustgojavaphpc#shellbashsqlvue

Works with

  • cursor
  • cli
  • api

Security analysis

B89/100
  • highPerforms destructive filesystem operations
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 2 files and shows the line behind each finding

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill event-contract-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Event Contract Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Event Contract Scan
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-event-contract-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-event-contract-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: event-contract-scan
description: "Event contracts: schema compatibility, tolerant readers, naming, event-store appends, projections — read-only."
kind: skill
pack: distributed-systems
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: event-contract-scan

## Premise

An event outlives the code that wrote it. It sits in a topic, a dead-letter queue, an event store or a replay corpus while producers and consumers deploy on their own schedules, so every change to its shape is a change to a contract nobody re-signs. A required field added to the producer's schema rejects every event already stored; a field removed breaks every consumer still reading it; an enum symbol added crashes a reader that throws on the unknown; `amount` that quietly moves from minor to major units passes every compatibility tool and charges a hundred times over. Event-sourced systems add their own: an append with no expected version lets two deciders both win, a custom event table with no `UNIQUE(stream, version)` stores both, a projection that increments and saves its checkpoint separately double-counts on every redelivery, an `apply()` that sends mail resends it on every replay, and a UI that redirects to a projection right after a command shows the old state. This skill finds those places and proves each with its oracle. Read-only: it never edits code and never touches a registry, broker or store that is not a scratch container.

**A compatibility tool is an oracle only when it parsed both sides.** Measured on 2026-10-14 (the tool lane below): json-schema-diff exits **1** both for "breaking" and for a file it could not parse; `asyncapi diff -t breaking` exits **0** on a required payload field added (payload changes are *unclassified*); oasdiff reads a head with no paths as "every path removed"; buf exits **1** on "no .proto files" and **100** on a misaligned `--against` root. So a run prints one of `PASS`, `BREAKING`, `NOT-RUN (exit n: <first line>)` or `blocked (lane not installed)` — a tool that parsed nothing is **NOT-RUN, never PASS**, and with no base to compare against §1 and §4 print NOT-RUN.

It owns `/ds-audit`'s Contracts (§1–§5) and Event sourcing (§6–§10) ledger rows. `@event-sourcing-architect` (this pack) judges its rows.

**Every finding cites the schema, event type or append `<file:line>` + the base it was compared against (`--contract-base=<dir>` or the merge-base) + the reader or decider that breaks + the [`broker-semantics.md`](../../../../snippets/broker-semantics.md) row its verdict rests on (SZ1–SZ13, EV1–EV3), read at the installed version + the tool exit or the oracle that showed it, or the test that would settle it + the fix in the project's own primitive** (its schema module, its event envelope, its store's expected-version API, its projection base). A grep hit is a candidate. "Contracts look compatible" is never printed; the output is the class-coverage block (§ Output). Verification follows [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/distributed.md`](../../../../snippets/precedents/distributed.md) § 28–30. The oracles are [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Distributed, V323–V327.

## The rule

A candidate is a finding when all four hold — the protocol's gates as `precedents/distributed.md` § Reading the seven gates applies them.

1. **Part A holds: this is an event contract, an event-store write or a projection** that another process, a replay or a later release reads. A schema another service consumes, an envelope on a broker topic, an append to a stream, a read model fed from events. An HTTP request DTO, an LLM tool-parameter schema, a websocket push to a browser, a config schema or a type used inside one process fails here.
2. **Part B holds at the installed version.** The format's rule or the store's default is a named row: a reader field with no default (SZ2), `BACKWARD` checked against the latest version only (SZ3), Jackson's `FAIL_ON_UNKNOWN_PROPERTIES` on a plain `new ObjectMapper()` (SZ6), KurrentDB's Node `appendToStream` defaulting to `streamState: ANY` (EV1), Marten's plain `Append` with no guard (EV3). A verdict with no row is invalid.
3. **Nothing already controls it**, across files and repositories. A registry with `_TRANSITIVE` compatibility and auto-registration off, a CI compatibility gate, a Pact provider verification with `can-i-deploy`, a tolerant reader configured on the shared mapper, an upcaster for the old version, a library store that owns uniqueness (precedent 30), a projection write and checkpoint in one transaction. **Read the consumers in this repo and name the ones outside it**: a change with no reader anywhere is not a break.
4. **The oracle fired, or the fact is two-ended.** The format tool exited breaking on base → head (tier T, V323), a fixture or replay corpus failed (V324), two deciders both appended (V325), a redelivered event changed the read model (V326), or a static fact cites both ends — the removed field's `file:line` and the reader that dereferences it. Without one the row is `[report-flagged]` / Unconfirmed with the settling test written out.

## Boundaries

One class, one owner. A neighbour that sees the same `file:line` lists it as corroboration.
- **HTTP API drift** (an OpenAPI spec against the running server, request and response shapes) → backend `http-contract-scan` § 10 (its `oasdiff` lane), when the backend pack is installed. §1 here runs `oasdiff` only on **webhook** payloads this service sends. **Job payload drift inside one service across a deploy** (a job enqueued by the old release, run by the new) → backend `job-lifecycle-scan` § 3; this skill owns events that cross a process or service boundary. **Money as a float in a payload** → backend `wire-format-scan` § 5; §4 cites it when the unit or encoding of an existing event changes.
- **HTTP type drift — a shared contracts-package type re-declared by hand** (a type exported or `z.infer`-ed in the contracts package, and a hand-written `interface` with the same name in a web or mobile app) → code-quality `type-safety-scan` § 1, when that pack is installed: the client reads the response through its own copy, so a field the package renames compiles on both sides and fails at run time; the fix imports the package's type or schema. The server's side of the same shape stays backend `http-contract-scan` § 10. 5c prints these because its name rule cannot tell an HTTP payload from an event; part A routes them, never as a broker row. Without code-quality they are reported here tagged `HTTP type drift (owner pack not installed)`.
- **A parsed message cast to a type with no runtime check** (`JSON.parse(raw) as OrderEvent`, `cast(Order, json.loads(body))`) → code-quality `type-safety-scan` § 1, when that pack is installed: its measured BAD lines are exactly these, so §2 prints `→ code-quality type-safety-scan §1 (k)` and does not grade them. §2 owns the contract half — a topic whose schema is in S1–S3 consumed without its schema or registry deserializer, and what a malformed event does to the consumer. Without the code-quality pack §2 reports the casts, tagged `boundary cast (owner pack not installed)`.
- **NestJS microservice transports the global pipe never reaches** (`@EventPattern` / `@MessagePattern` without `inheritAppConfig` or `@UsePipes`) → backend `validation-boundary-scan` § 8; **unknown-key policy on HTTP DTOs** (`forbidNonWhitelisted`, a strict request schema) → its § 6. A strict reader on an **outbound** DTO is not §3 (precedent 28).
- **What happens to an event that keeps failing** (poison, DLQ, requeue loop) → `messaging-scan` (this pack) § 3; §2 and §3 here hand over a crash-looping malformed or unknown event with the contract mechanism as evidence. **A consumer with no reserve** → `messaging-scan` § 2; §8 here owns a **projection's** write and checkpoint.
- **A gap-blind catch-up cursor over an event table** (`WHERE id > $last`) → `dual-write-scan` (this pack) § 6. **Events ordered by a wall-clock timestamp across streams** (`ORDER BY created_at` in a projector) → `coordination-scan` (this pack) § 9; 9d prints its count. **Workflow history versioning** (a Temporal change with executions open) and a process manager's compensation → `workflow-scan` (this pack) § 2 and § 4–§ 6; the event schemas a process manager reads are §1–§5 here.
- **An async projection daemon on every replica** (Marten `DaemonMode.Solo` behind an HPA) → performance `request-path-scan` § 20 (V40), when that pack is installed; without it reported here tagged `once-per-fleet (owner pack not installed)`. **A read after a write on a lagging database replica** → database `connection-config-scan` § 8; §10 owns the projection lag.
- **A decide-append on a balance, stock or credit stream** → security `logic-race-scan` § 5, when the security pack is installed, with the missing expected version as evidence; without it reported here tagged `money invariant (owner pack not installed)`.
- **Writing consumer-driven contract tests** (Pact message pacts, Spring Cloud Contract) → testing `contract-test`. §1 reads whether they run and gate a deploy.

## Adapt to the codebase — the contract lanes

Read every schema, registry, contract and event-store library **with its version** (F1). Facts are rows of `broker-semantics.md` § Event contracts and event stores (tagged and dated there); a row tagged `[K?]` makes its verdict Unconfirmed until the settling test runs. The event-store lanes beyond Node (Marten, KurrentDB .NET and Python, Axon, the Python `eventsourcing` library) and the typed-deserializer lanes are in [`stacks.md`](stacks.md), loaded only when F1 printed that stack.

| Lane | What decides compatibility | Tool (§1) | Rows |
|---|---|---|---|
| **Protobuf** (`*.proto`, `buf.yaml`) | field numbers and types; FILE is buf's default category, WIRE_JSON the recommended minimum for events | `buf breaking <module> --against <base>/<module>` | SZ1 |
| **Avro** (`*.avsc`, a Confluent registry) | a reader field with no default the writer lacks is an error; a writer enum symbol unknown to the reader needs the reader's `default`; renames only through aliases; `BACKWARD` checks the latest version only | the `avsc` resolver both ways (block below), or the registry's `test-compatibility` | SZ2, SZ3 |
| **JSON Schema** (`$schema` = json-schema.org) | open vs closed content model (`additionalProperties`); `required`; types | `json-schema-diff` both directions | SZ5 |
| **AsyncAPI 2/3** | channel addresses and operations (classified); message payloads (**unclassified** — compare them as JSON Schema) | `asyncapi diff -t breaking`, plus the unclassified count | SZ4 |
| **OpenAPI `webhooks:`** this service sends | a webhook body is judged as a request: removed or retyped properties break receivers | `oasdiff breaking --fail-on WARN`, read for direction | SZ7 |
| **TS-only event types** (no schema file) | the old consumer compiled against the new type | read the diff of the type (5c/§4); a `tsc` run on a scratch copy is the oracle | — |
| **Consumer-driven** — Pact message pacts, Spring Cloud Contract | the provider's `MessageProviderPact.verify()` in CI; `pact-broker can-i-deploy` before a deploy (pact-js 17.1.4, research notes B § 5.0, 2026-10-14) | the CI line in S6 is the gate | — |
| **Event stores** — KurrentDB Node (`@kurrent/kurrentdb-client`), Emmett, a custom table through an ORM | Node `appendToStream` defaults to `ANY`; a custom table owns no uniqueness until it declares it | 6a, 7a–7c | EV1; others in [`stacks.md`](stacks.md) (EV2, EV3) |

### The tool lane — exit codes (measured 2026-10-14, build)

| Tool | Clean | Breaking | NOT-RUN shapes | Traps the block guards |
|---|---|---|---|---|
| buf 1.73.0 | 0 | 100, with `file:line:col:` lines | 1: "had no .proto files", a missing path, a missing branch, a shallow clone without the base branch | `--against '.git#branch=main'` from a module that is not the repo root, without `subdir=`, exits 100 on an **additive** change ("Previously present file … was deleted"): roots misaligned. The block compares directory to directory and turns that line into NOT-RUN when the file exists in head |
| json-schema-diff 1.0.0 | 0 | 1 + "Breaking changes found" | 1 + `VError` (a parse error, a missing file, an unresolved `$ref`, a 2019-09 or 2020-12 `$schema`: `no schema with key or ref "https://json-schema.org/draft/2020-12/schema"`, measured 2026-10-14) — the **same exit code** as breaking; the block prints a 2019-09/2020-12 document NOT-RUN before running it | base → head is backward only: a head emptied to `{}` passes it and fails head → base, so both directions run. `enum`, `const`, `pattern`, `format`, `if`/`then`/`else` are **not compared** (the package's `KEYWORDS.md`): an added enum symbol prints "No differences found" — the block prints `PARTIAL` |
| @asyncapi/cli 6.2.1 | 0 | 1 + `DiffBreakingChangeError` | 1 + `ValidationError` | a required payload field added is `unclassified`, exit 0. An `info.version` edit alone (2.2.0 → 2.3.0) exits **1** as `"path": "/info/version", "type": "breaking"` (measured 2026-10-14): a correctly bumped document reads as broken — the block prints `VERSION-BUMP` when `/info/version` is the only breaking path. The CLI writes `~/.asyncapi-analytics` and reports each run to a remote metrics endpoint unless `CI=true` or `ASYNCAPI_METRICS_CONFIG_PATH` names a file with analytics off — the block sets both |
| oasdiff 1.33.0 | 0 | 1 at the `--fail-on` level | 102 (a file that did not load) | a head reduced to `openapi: 3.0.3` exits 1 as "api-path-removed": check S4 parsed. A webhook property removed is WARN (`--fail-on ERR` exits 0); a property newly *required* in what we send is ERR though receivers are unaffected. Never install it from npm (a placeholder package, SZ7) |
| avsc 5.7.9 resolver | 0 | 3 (the block's one-liner) | 2 (a schema that did not parse) | `createResolver` takes reader ← writer: run both directions; an added enum symbol fails forward only |

## Read the code — per event, before any §

Start from F2's contract inventory and the consumers, not from vocabulary. For each event type, answer these; the § each answer feeds is in brackets.
- **Who reads this event, where, and on which release?** The consumers in this repo (5b, the subscribe lines), the ones outside it (an AsyncAPI `receive` operation, a Pact consumer, a README), and the stored events a replay will read [§1, §5].
- **Which direction must the change be compatible in?** Consumers upgrade first (backward), producers first (forward), or independently (full); is the registry mode transitive for a topic whose retention is long or infinite [§1]?
- **Where is the payload checked against its contract** — a registry deserializer, a generated class, a zod or pydantic parse on the path — and what does the consumer do with a malformed one: park it, or throw and redeliver it forever [§2]?
- **What does the reader do with what it does not know** — an extra field, a new enum symbol, a new event type? Is the strict schema on the inbound or the outbound side [§3]?
- **Did the meaning change while the shape stayed** — a unit, a scale, a time zone, what `null` means, what a status covers [§4]?
- **Is the name in one place** — a shared constant, a generated client, the AsyncAPI document — and does the envelope carry a version the readers branch on, with an upcaster for each old one [§5]?
- **For a store:** what did the decider read before it appended, and does the append carry that version; what keeps two writers from both appending version n+1 [§6, §7]?
- **For a projection:** is its write idempotent per event, and does the checkpoint commit in the same transaction; does `apply` only mutate state; does a snapshot carry the schema version it was written with [§8, §9]?
- **For a UI flow:** after the command returns, what does the next screen read, and does it wait for the position the command returned [§10]?

## Pre-flight greps

Run as **one** script from the repo root (shell state does not survive between tool calls; bash 3.2 has no `mapfile`). Grep **N** feeds detector **§N**; F lines are the gates and the inventory. Every `rg` names its path — `.` or `"${ROOTS[@]}"`: given no path, `rg` reads stdin under an agent's shell and reports a silent zero. Include globs come **first** in every command, exclusions last (for a path several globs match, the last one wins). Any `rg` exit 2 is a failed grep, never zero hits. File-level greps use `--files-without-match`, never `rg -L` (that is `--follow`). Migrations are searched **on purpose** for §7: the schema is the evidence.

```bash
export LC_ALL=C; ROOTS=(.)   # server and client code; in a monorepo name the apps. Schema, migration and CI greps always search .
CODE=(-g '*.{ts,mts,cts,tsx,js,mjs,cjs,jsx,py,java,kt,go,cs,rb,php}')
XT=(-g '!**/node_modules/**' -g '!**/{dist,build,out,coverage,.next,.nuxt,.output,generated,__generated__,vendor,target,bin,obj}/**'
    -g '!**/{test,tests,__tests__,spec,specs,e2e,fixtures,__fixtures__,mocks,__mocks__,testdata}/**'
    -g '!**/*.{spec,test,e2e-spec,fuzz,stories}.*' -g '!**/{test_*,*_test}.{py,go}' -g '!**/*{Test,Tests,IT}.{java,kt,cs}'
    -g '!**/*.d.ts' -g '!**/*.min.*' -g '!**/{docs,ai,.claude,.refract-fixture}/**')
X=("${XT[@]}" -g '!**/{migrations,migration,seeders,seeds}/**' -g '!**/{locales,locale,i18n,translations}/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(?://|/\*|\*(?:\s|/|$)|#)')
T=$(mktemp -d "${TMPDIR:-/tmp}/ecs.XXXXXX"); trap 'rm -rf "$T"' EXIT   # lists and base copies live here; nothing is written into the repo
CONS='@Processor\(|\bextends\s+\w*WorkerHost\b|\bnew\s+Worker\(|\beachMessage\b|\beachBatch\b|\.subscribe\(|\.consume\(|@(?:EventPattern|MessagePattern|OnEvent|EventsHandler|SqsMessageHandler|RabbitSubscribe)\(|@(?:KafkaListener|RabbitListener|SqsListener|JmsListener|StreamListener)\b|\bIConsumer<|@(?:shared_task|app\.task)\b|\bhandleMessage\b|\bon_message\b|\bConsumer\(|\bmessage_handler\b'
# F1 schema, contract and event-store libraries with their declared versions (gate 3: SZ1-SZ7, EV1-EV3 are read at these); F1L the locked versions (pnpm, npm, yarn) — the lockfile wins over the range
rg -n --no-heading --pcre2 '"(?:@bufbuild/[\w-]+|protobufjs|google-protobuf|@grpc/proto-loader|avsc|@kafkajs/confluent-schema-registry|@confluentinc/schemaregistry|@asyncapi/[\w-]+|json-schema-diff|ajv|zod|valibot|class-validator|@sinclair/typebox|@pact-foundation/[\w-]+|@kurrent/kurrentdb-client|@eventstore/db-client|@event-driven-io/emmett[\w-]*|@nestjs/cqrs|@nestjs/microservices)"\s*:\s*"[^"]+"' -g '**/package.json' -g '!**/node_modules/**' .
rg -n --no-heading -U --pcre2 '^[ \t]*["\x27]?/?(?:@bufbuild/[\w-]+|protobufjs|google-protobuf|@grpc/proto-loader|avsc|@kafkajs/confluent-schema-registry|@confluentinc/schemaregistry|@asyncapi/[\w-]+|json-schema-diff|ajv|zod|valibot|class-validator|@sinclair/typebox|@pact-foundation/[\w-]+|@kurrent/kurrentdb-client|@eventstore/db-client|@event-driven-io/emmett[\w-]*|@nestjs/cqrs|@nestjs/microservices)@\d[\w.-]*|"node_modules/(?:@bufbuild/[\w-]+|protobufjs|google-protobuf|@grpc/proto-loader|avsc|@kafkajs/confluent-schema-registry|@confluentinc/schemaregistry|@asyncapi/[\w-]+|json-schema-diff|ajv|zod|valibot|class-validator|@sinclair/typebox|@pact-foundation/[\w-]+|@kurrent/kurrentdb-client|@eventstore/db-client|@event-driven-io/emmett[\w-]*|@nestjs/cqrs|@nestjs/microservices)":\s*\{\s*\n\s*"version":\s*"[^"]+"|^"?(?:@bufbuild/[\w-]+|protobufjs|google-protobuf|@grpc/proto-loader|avsc|@kafkajs/confluent-schema-registry|@confluentinc/schemaregistry|@asyncapi/[\w-]+|json-schema-diff|ajv|zod|valibot|class-validator|@sinclair/typebox|@pact-foundation/[\w-]+|@kurrent/kurrentdb-client|@eventstore/db-client|@event-driven-io/emmett[\w-]*|@nestjs/cqrs|@nestjs/microservices)@[^\n]*:\s*\n\s+version:?\s+"?[\d.]+' -g '**/{pnpm-lock.yaml,package-lock.json,yarn.lock}' -g '!**/node_modules/**' .
rg -n --no-heading -i --pcre2 '\b(?:Marten|KurrentDB\.Client|EventStore\.Client[\w.]*|Confluent\.SchemaRegistry[\w.]*|PactNet|axon[\w-]*|kafka-avro-serializer|kafka-protobuf-serializer|kafka-json-schema-serializer|kafka-schema-registry-maven-plugin|spring-cloud-contract[\w-]*|au\.com\.dius\.pact[\w.:-]*|jackson-databind|eventsourcing|kurrentdbclient|esdbclient|pydantic|pact-python|fastavro|confluent-kafka)\b' -g '**/{requirements*.txt,pyproject.toml,Pipfile,pom.xml,build.gradle,build.gradle.kts,*.csproj,Directory.Packages.props,go.mod,Gemfile,composer.json}' -g '!**/node_modules/**' .
# F2 the contract inventory. S1 proto/Avro files; S2 AsyncAPI documents; S3 JSON Schemas (json-schema.org $schema only — tool configs point at schemastore);
#    S4 OpenAPI documents with webhooks; S5 buf breaking config and registry compatibility settings; S6 the CONTRACT GATE in CI (a compatibility tool or Pact verify/can-i-deploy)
rg --files -g '*.{proto,avsc,avdl}' "${XT[@]}" . | sort > "$T/s1"; echo "S1 proto/avro $(wc -l < "$T/s1" | tr -d ' ')"; head -20 "$T/s1"
rg -l --pcre2 '^\s*["\x27]?asyncapi["\x27]?\s*:\s*["\x27]?[23]\.' -g '*.{yml,yaml,json}' "${XT[@]}" . | sort > "$T/s2"; echo "S2 asyncapi $(wc -l < "$T/s2" | tr -d ' ')"; head -20 "$T/s2"
rg -l --pcre2 '"\$schema"\s*:\s*"https?://json-schema\.org/' -g '*.json' "${XT[@]}" . | sort > "$T/s3"; echo "S3 json-schema $(wc -l < "$T/s3" | tr -d ' ')"; head -20 "$T/s3"
rg -l --pcre2 '^webhooks:|"webhooks"\s*:\s*\{' -g '*.{yml,yaml,json}' "${XT[@]}" . | sort > "$T/s4"; echo "S4 openapi-webhooks $(wc -l < "$T/s4" | tr -d ' ')"; head -20 "$T/s4"
rg -n --no-heading -i --pcre2 '^\s*breaking:|\b(?:schema\.)?compatibility(?:[._]?level)?\b["\x27]?\s*[:=]\s*["\x27]?(?:BACKWARD|FORWARD|FULL|NONE)(?:_TRANSITIVE)?\b|\bauto[._]?register[._]?schemas\b|\buse[._]?latest[._]?version\b' --hidden -g '**/buf.yaml' -g '*.{yml,yaml,json,properties,conf,env,ts,js,java,kt,cs,py}' -g '!.git/**' -g '!**/node_modules/**' -g '!**/package-lock.json' "${XT[@]}" . | "${NC[@]}"
rg -n --no-heading --pcre2 '\bbuf\s+breaking\b|\basyncapi\s+diff\b|\bjson-schema-diff\b|\boasdiff\s+breaking\b|\bcan-i-deploy\b|\btest-compatibility\b|\bMessageProviderPact\b|\bpact(?:-provider)?[\w-]*\s+verify\b' --hidden -g '**/.github/**' -g '**/.gitlab-ci.yml' -g '**/{Makefile,Jenkinsfile,azure-pipelines.yml,bitbucket-pipelines.yml,package.json}' -g '**/.circleci/**' -g '!**/node_modules/**' .
# 2a a message body parsed straight into an object (a parse wrapped in a schema call is excluded by the lookbehind); 2b the 2a files with no schema or validator call at all; 2c 2a lines in a consumer file
rg -n --no-heading --pcre2 '(?<![\w.]\()\bJSON\.parse\(\s*(?:\w+(?:\?|!)?\.)*(?:value|content|body|data|Body|payload|message|msg)\b|(?<![\w.]\()\bjson\.loads\(\s*(?:\w+\.)?(?:value|body|data|payload|message|msg|raw)\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}" > "$T/p2"
echo "2a $(wc -l < "$T/p2" | tr -d ' ') parses"; cut -d: -f1 "$T/p2" | sort -u | tr '\n' '\0' | xargs -0 -r rg --files-without-match --pcre2 '(?<!\bJSON)\.(?:parse|safeParse|parseAsync|safeParseAsync|validate\w*|decode|assert\w*|check)\(|\bplainToInstance\(|\bvalidate(?:OrReject|Sync)?\(|\bmodel_validate(?:_json)?\(|\bparse_obj\(|\bTypeAdapter\(|\bajv\b|\bValue\.Check\(|\bis\w+Event\(' > "$T/p2b"; echo "2b files with no validator: $(wc -l < "$T/p2b" | tr -d ' ')"; cat "$T/p2b"
cut -d: -f1 "$T/p2" | sort -u | tr '\n' '\0' | xargs -0 -r rg -l --pcre2 "$CONS" > "$T/p2c"; echo "2c in a consumer file: $(rg -c -F -f "$T/p2c" "$T/p2" 2>/dev/null || echo 0)"; rg -F -f "$T/p2c" "$T/p2" 2>/dev/null
# 3a a switch or guard that throws on an unknown type; 3b strict readers in code, and additionalProperties:false in schema documents and consumer files only (inbound or outbound is the read, precedent 28);
#    3c tolerance markers that clear 3b; 3d Avro enums with no default (a new symbol breaks old readers, SZ2)
rg -n --no-heading --pcre2 'default:\s*(?:return\s+)?assertNever\(|default:\s*throw\s+new\s+\w*Error\(\s*[\x60"\x27](?:Unknown|Unhandled|Unexpected|Unsupported)\s+(?:event|message|type)|const\s+_\w*\s*:\s*never\s*=|\bcase\s+_\s*:\s*raise\b|\bassert_never\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '\.strict\(\)|\bextra\s*=\s*["\x27]forbid["\x27]|\bExtra\.forbid\b|UnmappedMemberHandling\.Disallow\b|=\s*new\s+ObjectMapper\(\)' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
{ cat "$T/s2" "$T/s3"; rg -l --pcre2 "$CONS" "${CODE[@]}" "${X[@]}" "${ROOTS[@]}"; } | sort -u | tr '\n' '\0' | xargs -0 -r rg -H -n --no-heading --pcre2 '["\x27]?additionalProperties["\x27]?\s*:\s*false'
rg -n --no-heading --pcre2 'FAIL_ON_UNKNOWN_PROPERTIES\s*,\s*false|\bignoreUnknown\s*=\s*true|\.passthrough\(\)|\.catchall\(|\bextra\s*=\s*["\x27](?:ignore|allow)["\x27]|READ_UNKNOWN_ENUM_VALUES_(?:USING_DEFAULT_VALUE|AS_NULL)|@JsonEnumDefaultValue' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading -U -o -r '$1' --pcre2 '"type"\s*:\s*"enum"\s*,\s*"name"\s*:\s*"(\w+)"(?:(?!"default")[^}])*\}' -g '*.avsc' "${XT[@]}" .
# 5a one topic or queue name spelled as a literal in 2+ files, and near-twins (orders.placed / order.placed); 5b names produced but never consumed here, and the reverse;
#    5c one *Event/*Message/*Payload type declared in 2+ services; 5d version fields and upcasters (their absence in a store whose events changed shape is the read)
rg -o --no-heading --no-line-number --pcre2 '\b(?:topic|topics|queue|queueName|routingKey|routing_key|exchange)\s*[:=]\s*\[?\s*["\x27]\K[\w.:/-]{3,}(?=["\x27])|@(?:EventPattern|MessagePattern|OnEvent|Processor|KafkaListener|RabbitListener|SqsListener)\(\s*(?:topics\s*=\s*)?["\x27]\K[\w.:/-]{3,}(?=["\x27])|\.(?:emit|publish|subscribe|send|add)\(\s*["\x27]\K[\w-]+(?:[.:/][\w-]+)+(?=["\x27])' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" > "$T/n5"
awk '{i=index($0,":"); print substr($0,i+1)"\t"substr($0,1,i-1)}' "$T/n5" | sort -u | awk -F'\t' '{n[$1]++; f[$1]=f[$1]" "$2} END {for (k in n) if (n[k]>1) print "5a " n[k] " files  " k " :" f[k]}' | sort -k2,2nr | head -40
awk '{print substr($0,index($0,":")+1)}' "$T/n5" | sort -u | awk '{k=tolower($0); gsub(/[._:\/-]/,"",k); sub(/s$/,"",k); g=k; gsub(/s/,"",g); if (seen[g] && seen[g]!=$0) print "5a near-twin  " seen[g] "  " $0; else seen[g]=$0}'
rg -o --no-filename --no-line-number --pcre2 '\b(?:subscribe|consume)\(\s*\{?\s*(?:topics?|queue|subject)\s*:\s*\[?\s*["\x27]\K[\w.:/-]+|@(?:EventPattern|MessagePattern|OnEvent|KafkaListener|RabbitListener|SqsListener)\(\s*(?:topics\s*=\s*)?["\x27]\K[\w.:/-]+|\bnew\s+Worker\(\s*["\x27]\K[\w.:/-]+|@Processor\(\s*["\x27]\K[\w.:/-]+' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u > "$T/sub"
rg -o --no-filename --no-line-number --pcre2 '\b(?:send|sendMessage|publish|produce|emit)\(\s*(?:\{\s*(?:topic|queue|subject|exchange|channel)\s*:\s*)?["\x27]\K[\w-]+(?:[.:/][\w-]+)+|\bnew\s+Queue\(\s*["\x27]\K[\w.:/-]+|\bregisterQueue\(\s*\{\s*name\s*:\s*["\x27]\K[\w.:/-]+' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | sort -u > "$T/pub"
echo "5b produced, never consumed here: $(comm -23 "$T/pub" "$T/sub" | tr '\n' ' ')"; echo "5b consumed, never produced here: $(comm -13 "$T/pub" "$T/sub" | tr '\n' ' ')"
rg -o -n --no-heading --pcre2 '(?:\binterface|\btype|\bclass|\brecord|^\s*message)\s+\K\w+(?:Event|Message|Payload)\b(?=\s*(?:[{=(<:]|extends|implements|$))' "${CODE[@]}" -g '*.proto' "${X[@]}" "${ROOTS[@]}" | awk -F: '{split($1,d,"/"); print $NF"\t"d[2]"/"d[3]"\t"$1}' | sort -u | awk -F'\t' '{if (!((($1) SUBSEP $2) in seen)) {seen[$1,$2]=1; n[$1]++}; f[$1]=f[$1]" "$3} END {for (k in n) if (n[k]>1) print "5c " n[k] " services  " k " :" f[k]}' | sort -k2,2nr | head -30
rg -c --pcre2 '\b(?:schemaVersion|schema_version|eventVersion|event_version|payloadVersion|dataschema|specversion)\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | head -20; cat "$T/s1" "$T/s2" "$T/s3" | tr '\n' '\0' | xargs -0 -r rg -c --pcre2 '\b(?:schemaVersion|schema_version|eventVersion|event_version|version)\b'
rg -n --no-heading --pcre2 '\b(?:\w*Upcaster|SingleEventUpcaster|EventUpcaster|upcast\w*|Upcast<|registerUpcaster|eventUpcasters)\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}" | head -20
# ES evidence (§6-§9 read it): an event-store client call or an aggregate base (es0); an event table carrying a stream id and a version (es7)
rg -l0 --pcre2 '\.appendToStream\(|\bAppendToStreamAsync\(|\bappend_to_stream\(|\.Events\.(?:Append|StartStream|FetchForWriting|AggregateStreamAsync)\b|\bextends\s+\w*Aggregate\w*\b|\bAggregateRoot\b|@Aggregate\b|@EventSourcingHandler\b|\bloadFromHistory\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" > "$T/es0"
rg -l0 -i --pcre2 'create\s+table\s+(?:if\s+not\s+exists\s+)?["\x60\w.]*event' -g '*.sql' "${XT[@]}" . | xargs -0 -r rg -l0 -i --pcre2 '\b(?:stream|aggregate)_?id\b' | xargs -0 -r rg -l0 -i --pcre2 '\b(?:version|seq\w*|revision)\b' > "$T/es7"
echo "ES evidence: $(tr -cd '\0' < "$T/es0" | wc -c | tr -d ' ') store/aggregate files, $(tr -cd '\0' < "$T/es7" | wc -c | tr -d ' ') event tables"
# 6a a Node append with no expected version (EV1: the default is ANY) — Marten, KurrentDB .NET/Python → stacks.md
rg -n --no-heading --pcre2 '\.appendToStream\(\s*[^,]+,\s*[^,{}]+(?:\([^()]*\))?\s*\)\s*;|\.appendToStream\([^\n]*\b(?:streamState|expectedRevision)\s*:\s*ANY\b|\bappendToStream\([^\n]*\bexpectedStreamVersion\s*:\s*undefined\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 7a es7 tables lacking UNIQUE/PK(stream, version); 7b a later unique index that clears 7a; 7c Prisma models and ORM entities with a stream id and no composite unique
xargs -0 -r rg --files-without-match -i --pcre2 '(?:unique|primary\s+key)\s*\(\s*["\x60\w]*(?:stream|aggregate)\w*["\x60]?\s*,\s*["\x60\w]*(?:version|seq|sequence|revision|position)' < "$T/es7"
rg -n --no-heading -i --pcre2 'create\s+unique\s+index[^\n]*\bon\s+["\x60\w.]*event\w*["\x60]?\s*\([^)]*(?:stream|aggregate)\w*[^)]*(?:version|seq|revision|position)' -g '*.{sql,ts,js,py}' "${XT[@]}" .
rg -n --no-heading -U --pcre2 '^\s*model\s+\w*Event\w*\s*\{(?=[^}]*\b(?:streamId|stream_id|aggregateId|aggregate_id)\b)(?:(?!@@(?:unique|id)\(\s*\[\s*\w*(?:stream|aggregate)\w*\s*,)[^}])*\}' -g '*.prisma' "${XT[@]}" . | rg --pcre2 '^\S+:\d+:\s*model\b'
rg -l0 --pcre2 '\bclass\s+\w*Event\w*\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -l0 --pcre2 '@Entity\b|@Table\b|\bmodels\.Model\b|\bpgTable\(|\b__tablename__\b' | xargs -0 -r rg -l0 --pcre2 '\b(?:streamId|stream_id|aggregateId|aggregate_id)\b' | xargs -0 -r rg --files-without-match --pcre2 '@Unique\(|@Index\([^)]*unique|unique_together|UniqueConstraint\(|@@unique|\bunique\(\s*\[|\.unique\(\)'
# 8a projection files (an event-handler registration, a projection class, a projector or read-model file name) that increment, save a checkpoint, or insert with no conflict guard
rg -l0 --pcre2 '@(?:EventsHandler|EventHandler|OnEvent|Projection)\b|\bclass\s+\w*(?:Projection|Projector|ReadModel)\w*|\b(?:project|when|on)\w*\s*\(\s*\w+\s*:\s*\w+Event\b' "${CODE[@]}" "${X[@]}" -g '!**/*.{tsx,jsx,vue,svelte}' "${ROOTS[@]}" > "$T/proj0"; rg --files -0 -g '**/*{projector,read-model,readmodel,read_model}*.{ts,mts,js,mjs,py,java,kt,go,cs,rb,php}' "${X[@]}" "${ROOTS[@]}" >> "$T/proj0"
tr '\0' '\n' < "$T/proj0" | sort -u | tr '\n' '\0' | xargs -0 -r rg -H -n --no-heading --pcre2 '(?i:\bset\s+\w+\s*=\s*\w+\s*[+-]\s*(?:\d|\$|:|\?))|\$inc\b|\.increment\(|\bcheckpoints?\w*\.(?:save|set|update|store|upsert|commit)\(|\bsave\w*Checkpoint\(|(?i:insert\s+into\s+\w+(?![^\n]*on\s+conflict))' | "${NC[@]}"
# 9a apply/when/evolve bodies in aggregate files that do I/O, the body ending at the method's own closing line (read: rebuild must be pure); 9b snapshot code (saveSnapshot, OrderSnapshots, order_snapshots) in a file that names an aggregate or a stream id, with no version; 9c an async projection daemon (replicas → performance §20); 9d events ordered by a timestamp (→ coordination-scan §9)
rg -l0 --pcre2 '\bextends\s+\w*Aggregate\w*\b|\bAggregateRoot\b|@Aggregate\b|@EventSourcingHandler\b|\bloadFromHistory\(|\bevolve\s*[(=:]|\bclass\s+\w+Aggregate\b|\bdef\s+(?:apply|when|mutate)\s*\(\s*self\s*,\s*\w*event' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -n --no-heading -U -o -r '$2' --pcre2 '^([ \t]*)(?:(?:public|private|protected|override)\s+)*(?:async\s+)?(?:def\s+)?((?:apply|when|evolve|mutate|on[A-Z])\w*)\s*\([^)]*\)\s*(?::\s*[\w<>\[\]]+\s*)?[{:](?:(?!\1(?:\}|def\s|async\s+def\s))[^\n]*\n){0,12}?(?!\1(?:\}|def\s|async\s+def\s))[^\n]*(?:\bawait\s|\bfetch\(|\.send\w*\(|\.publish\(|\.emit\(|\bqueue\w*\.add\(|\bhttp\w*\.|\bmailer\b|\bemail\w*\.|\bsms\w*\.)' | "${NC[@]}"
[ -s "$T/es0" ] && rg -l0 -i --pcre2 'snapshot\w*\s*(?:=|\(|:)' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -l0 -i --pcre2 '\baggregate\w*|\bstream_?id\b|\bloadFromHistory\b|\bAggregateStream\w*' | xargs -0 -r rg --files-without-match -i --pcre2 '\bsnapshot_?(?:schema_?)?version\b|\bschemaVersion\b|\bstreamVersion\b|\bsnapshot\w*\.version\b|\bversion\s*[:=]'
[ -s "$T/es0" ] && rg -n --no-heading --pcre2 '\bDaemonMode\.(?:Solo|HotCold)\b|\bAddAsyncDaemon\(|\bstartProjectionDaemon\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -c -i --pcre2 'from\s+["\x60\w.]*events?\b[^\n]*order\s+by\s+(?:\w+\.)?(?:created_at|occurred_at|timestamp|ts|recorded_at)\b(?![^\n]*,\s*(?:id|version|position|seq))' "${CODE[@]}" -g '*.sql' "${X[@]}" "${ROOTS[@]}"
# 10 only where an async read model exists: 10a a command dispatched, then a redirect, in one file; 10b a client mutation, then navigation; 10c position or version waits that clear it
rg -l0 --pcre2 '\bclass\s+\w*(?:Projector|ReadModel|EventProjection|StreamProjection)\w*' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" > "$T/projR"; rg --files -0 -g '**/*{projector,read-model,readmodel,read_model}*.{ts,mts,js,mjs,py,java,kt,go,cs,rb,php}' "${X[@]}" "${ROOTS[@]}" >> "$T/projR"
if [ -s "$T/projR" ] || [ -s "$T/es0" ]; then
  rg -l0 --pcre2 '\bcommandBus\.execute\(|\bCommandBus\b|\bmediator\.Send\(|\b_mediator\.Send\(|\bsender\.Send\(|\bdispatch\(\s*new\s+\w+Command\(' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -0 -r rg -H -n --no-heading --pcre2 '\bres\.redirect\(|\breply\.redirect\(|@Redirect\(|\bRedirect(?:ToAction|ToRoute)?\(|\bredirect\(|\bHttpStatus\.SEE_OTHER\b|\b303\b' | "${NC[@]}"
  rg -n --no-heading -U --pcre2 '\b(?:await\s+[\w.]*(?:mutateAsync|post|create\w*|submit\w*)\(|method:\s*["\x27]POST["\x27])[^\n]*\n(?:[^\n]*\n){0,6}?[^\n]*\b(?:navigate|router\.(?:push|replace)|redirect|goto)\(' -g '*.{ts,tsx,js,jsx,vue,svelte}' "${X[@]}" "${ROOTS[@]}" | rg --pcre2 '\b(?:navigate|router\.(?:push|replace)|redirect|goto)\('
  rg -n --no-heading --pcre2 '\b(?:waitFor(?:Position|Version|Projection|Checkpoint)\w*|minPosition|expectedVersion|atLeastVersion|consistencyToken|readYourWrites|WaitForNonStaleProjectionData\w*|waitForNonStale\w*)\b' "${CODE[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}" | head -10
else echo "10 N/A (no projection or read-model class and no event store)"; fi
```

### The contract lane — §1 and §4 against a base

Appended to the block above, in the same script (it reads `$T/s1`–`$T/s4` and `$T/n5`). The base is `--contract-base=<dir>` — a tree that mirrors repo-relative paths (`<dir>/schemas/orders.avsc` is the base of `schemas/orders.avsc`), for a repo with no base branch or a fixture — passed as `CONTRACT_BASE`, else the merge-base with `BASE_BRANCH` (default `origin/main`), whose copies are read with `git show` into `$T`. No base → `BASE NOT-RUN`, and §1 and §4 print `NOT-RUN (no base)`. Tools resolve from `PATH` or `./node_modules/.bin`; a missing one prints `blocked`, and its schema's changed lines still print in 4 for the read.

```bash
printf '{"analyticsEnabled":"false","infoMessageShown":"true","userID":"none"}' > "$T/aa.json"   # the AsyncAPI CLI reports usage to a remote endpoint unless told not to
CB="${CONTRACT_BASE:-}"; BB="${BASE_BRANCH:-origin/main}"; B="$T/base"; mkdir -p "$B"; MB=""
cat "$T/s1" "$T/s2" "$T/s3" "$T/s4" | sed 's|^\./||' | sort -u > "$T/heads"
if [ -n "$CB" ]; then if [ -d "$CB" ]; then B="${CB%/}"; echo "BASE contract-base $B"; else B=""; echo "BASE NOT-RUN (contract-base $CB is not a directory)"; fi
elif MB=$(git merge-base HEAD "$BB" 2>/dev/null); then echo "BASE $BB @ $MB"
  { cat "$T/heads"; cut -d: -f1 "$T/n5" | sed 's|^\./||'; git diff --relative --name-only --diff-filter=D "$MB" -- '*.proto' '*.avsc' '*.avdl' '*.json' '*.yaml' '*.yml'; git ls-tree -r --name-only "$MB" -- . | rg --pcre2 '\.(?:proto|avsc|avdl)$'; } | sort -u > "$T/bl"
  while read -r p; do git cat-file -e "$MB:./$p" 2>/dev/null || continue; mkdir -p "$B/$(dirname "$p")"; git show "$MB:./$p" > "$B/$p"; done < "$T/bl"
else B=""; echo "BASE NOT-RUN (no merge-base with $BB: a shallow clone or no such branch; pass --contract-base=<dir>)"; fi
v() { if [ "$3" = 0 ]; then echo "1t $1 $2 PASS"; elif [ "$3" = "$4" ] && rg -q --pcre2 "$5" "$T/o"; then echo "1t $1 $2 BREAKING"; rg -v --pcre2 '^(?:WARNING|FATAL)\b|^\s*$' "$T/o" | head -6; else echo "1t $1 $2 NOT-RUN (exit $3: $(rg -m1 -v --pcre2 '^(?:WARNING|FATAL)\b|^\s*$' "$T/o"))"; fi; }
if [ -n "$B" ]; then
  { rg --files -g '*.{proto,avsc,avdl}' "$B"; rg -l --pcre2 '^\s*["\x27]?asyncapi["\x27]?\s*:|"\$schema"\s*:\s*"https?://json-schema\.org/|^webhooks:' -g '*.{json,yml,yaml}' "$B"; } | sed "s|^$B/||" | sort -u | while read -r p; do [ -e "$p" ] || echo "1d deleted since base: $p (a removed schema breaks every consumer still subscribed)"; done
  BUF=$(command -v buf || ls ./node_modules/.bin/buf 2>/dev/null); JSD=$(command -v json-schema-diff || ls ./node_modules/.bin/json-schema-diff 2>/dev/null)
  AAPI=$(command -v asyncapi || ls ./node_modules/.bin/asyncapi 2>/dev/null); OAS=$(command -v oasdiff); AVSC=$(node -e 'require.resolve("avsc")' 2>/dev/null && echo yes)
  # 1a protobuf (SZ1): one run per module root (a buf.yaml directory, else the protos' common directory); exit 0 clean · 100 breaking · anything else NOT-RUN
  MODS=$(rg --files -g '**/buf.yaml' "${XT[@]}" . | sed 's|/buf.yaml$||'); [ -z "$MODS" ] && MODS=$(rg --pcre2 '\.proto$' "$T/s1" | sed 's|/[^/]*$||' | awk 'NR==1{p=$0} {while (index($0"/", p"/")!=1) sub(/\/[^\/]*$/,"",p)} END{print p}')
  for m in $MODS; do [ -d "$B/$m" ] || { echo "1t buf $m NOT-RUN (no base copy)"; continue; }
    if [ -z "$BUF" ]; then echo "1t buf $m blocked (lane not installed: buf@1.73.0 — npm i -D @bufbuild/buf)"; continue; fi
    "$BUF" breaking "$m" --against "$B/$m" > "$T/o" 2>&1; e=$?
    rg -o -r '$1' --pcre2 'Previously present file "([^"]+)" was deleted' "$T/o" | while read -r f; do [ -e "$m/$f" ] && echo "1t buf $m NOT-RUN (roots misaligned: $f exists in head — a missing subdir= or a different module root)"; done
    v buf "$m" "$e" 100 '^\S+:\d+:\d+:'; done
  # 1b JSON Schema (SZ5): backward = old events read with the new schema; forward = new events read by old readers. enum, const, pattern, format, if/then/else are NOT compared by json-schema-diff 1.0.0
  for f in $(cat "$T/s3"); do f=${f#./}; [ -f "$B/$f" ] || continue; if [ -z "$JSD" ]; then echo "1t json-schema-diff $f blocked (lane not installed: json-schema-diff@1.0.0)"; continue; fi
    rg -q --pcre2 '"\$schema"\s*:\s*"https?://json-schema\.org/draft/20(?:19-09|20-12)/' "$f" "$B/$f" && { echo "1t json-schema-diff $f NOT-RUN (a 2019-09/2020-12 \$schema: json-schema-diff 1.0.0 cannot load its meta-schema, SZ9 — read the diff of required, properties and additionalProperties)"; continue; }
    "$JSD" "$B/$f" "$f" > "$T/o" 2>&1; v json-schema-diff "$f(backward)" $? 1 '^Breaking changes found'
    "$JSD" "$f" "$B/$f" > "$T/o" 2>&1; v json-schema-diff "$f(forward)" $? 1 '^Breaking changes found'
    rg -q --pcre2 '"(?:enum|const|pattern|format|if|patternProperties|dependencies)"\s*:' "$f" "$B/$f" && echo "1t json-schema-diff $f PARTIAL (enum/const/pattern/format/if not compared — read their diff)"; done
  # 1c AsyncAPI (SZ4): -t breaking classifies channels and operations; payload schema changes are 'unclassified' and never fail it — count them and compare the payloads (1b) or read
  for f in $(cat "$T/s2"); do f=${f#./}; [ -f "$B/$f" ] || continue; if [ -z "$AAPI" ]; then echo "1t asyncapi $f blocked (lane not installed: @asyncapi/cli@6.2.1)"; continue; fi
    CI=true ASYNCAPI_METRICS_CONFIG_PATH="$T/aa.json" "$AAPI" diff "$B/$f" "$f" -t breaking -f json > "$T/o" 2>&1; e=$?; n=$(rg -c --pcre2 '"path":' "$T/o")
    if [ "$e" = 1 ] && [ -n "$n" ] && [ "$n" = "$(rg -c --pcre2 '"path":\s*"/info/version"' "$T/o")" ]; then echo "1t asyncapi $f VERSION-BUMP (the only 'breaking' change is /info/version, SZ10 — no channel or operation broke; the payload verdict is the unclassified count)"; else v asyncapi "$f" "$e" 1 'DiffBreakingChangeError'; fi
    CI=true ASYNCAPI_METRICS_CONFIG_PATH="$T/aa.json" "$AAPI" diff "$B/$f" "$f" -t unclassified -f json > "$T/o" 2>&1; echo "1t asyncapi $f payload changes unclassified: $(rg -c --pcre2 '"path":\s*"[^"]*/payload' "$T/o" || echo 0)"; done
  # 1d OpenAPI webhooks (SZ7): oasdiff reads a webhook body as a request, so a property the receivers read can be removed at WARN — fail on WARN and read the direction
  for f in $(cat "$T/s4"); do f=${f#./}; [ -f "$B/$f" ] || continue; if [ -z "$OAS" ]; then echo "1t oasdiff $f blocked (lane not installed: oasdiff@1.33.0 — brew or the GitHub release, never npm)"; continue; fi
    "$OAS" breaking "$B/$f" "$f" --fail-on WARN > "$T/o" 2>&1; v oasdiff "$f" $? 1 '^\d+ changes: '; done
  # 1e Avro (SZ2): the avsc resolver both ways — exit 0 compatible · 3 incompatible · 2 a schema that did not parse
  for f in $(rg --pcre2 '\.avsc$' "$T/s1"); do f=${f#./}; [ -f "$B/$f" ] || continue; if [ -z "$AVSC" ]; then echo "1t avsc $f blocked (lane not installed: avsc@5.7.9)"; continue; fi
    for d in "$B/$f $f backward" "$f $B/$f forward"; do set -- $d
      node -e 'const a=require("avsc"),fs=require("fs");let w,r;try{w=a.Type.forSchema(JSON.parse(fs.readFileSync(process.argv[1],"utf8")));r=a.Type.forSchema(JSON.parse(fs.readFileSync(process.argv[2],"utf8")))}catch(e){console.log("PARSE "+e.message);process.exit(2)}try{r.createResolver(w);process.exit(0)}catch(e){console.log("INCOMPATIBLE "+e.message);process.exit(3)}' "$1" "$2" > "$T/o" 2>&1; v avsc "$f($3)" $? 3 '^INCOMPATIBLE'; done; done
  # 4 semantic drift (read-only): changed lines in event schemas and in files that name a topic (5a's files) carrying a unit factor, a rounding call or a unit-suffixed field
  { cut -d: -f1 "$T/n5" | sed 's|^\./||'; cat "$T/heads"; } | sort -u | while read -r p; do [ -f "$B/$p" ] && [ -f "$p" ] && diff -U0 "$B/$p" "$p" | sed "s|^|$p:|"; done > "$T/d4"
  rg --no-heading --no-line-number --pcre2 '^[^:]+:[+-](?![+-]{2})[^\n]*(?:[*/]\s*(?:100|1000|1e3|1e6|60|3600|86400)\b|\bMath\.(?:round|floor|ceil)\(|\btoFixed\(|\b\w+(?:Ms|Millis|Sec|Secs|Seconds|Cents|Minor|Major|Units)\b\s*["\x27]?\s*[:=?(]|"(?:description|format|x-unit|unit)"\s*:|\b(?:nullable|optional)\b)' "$T/d4" | head -40
fi
```

Reading the output:
- **F1 is the version gate** and the lane selector: the schema tooling, the registry client, Pact, the store. **F1L** prints the locked version: when it printed one, that is the version, not the range's floor (a package locked at two versions: the importer's entry in `pnpm-lock.yaml`, or `<app>/node_modules/<pkg>` in `package-lock.json`, says which one the app gets). A stack beyond Node loads [`stacks.md`](stacks.md). **F2 is the subject list.** S1–S4 empty and no TS event type in 5c means §1 is `N/A (no schema files)` and the CONTRACT GATE is `N/A`; S6 empty with schema files present is a gate gap the audit prints, not a finding. S5 shows the registry mode: `BACKWARD` on a topic with long retention is §1's transitive row (SZ3).
- **`1t` lines are verdicts of a tool, never of this run.** `BREAKING` is a candidate whose part A (a reader exists) still needs the read; `PASS` with a `PARTIAL` line means the enum and pattern half is unread; `NOT-RUN` and `blocked` are listed in the coverage block as such.
- **2a is the input; 2b the strong list.** A 2a line wrapped as `X.parse(JSON.parse(…))` is excluded; a cast `as T` goes to code-quality (§ Boundaries). 3b prints every strict reader; part A decides inbound vs outbound (precedent 28). 5a–5c print lists for the read, never verdicts: a name another vendor owns (a third-party webhook topic) refutes at part A.
- **ES evidence gates §6–§9.** Zero files and zero tables → `N/A (no event store, aggregate or event table)`. 9d's count is printed for `coordination-scan` § 9, never graded here.

Three blind spots are structural, so work around them instead of trusting a quiet grep:
- **The consumer lives in another repository.** A schema with no reader here (5b "produced, never consumed") is the normal shape of a platform event: name the consuming services from the AsyncAPI `receive` operations, the registry's subjects or the README, and keep the §1 row Unconfirmed until one is read (precedent 5's shape).
- **The schema is generated.** A schema emitted from code (zod-to-json-schema, a protobuf from annotations) has no committed base; the diff of the source type is the evidence, and the generator run on base and head is the oracle.
- **Names built at run time** (`${domain}.${event}`) never reach 5a or 5b: read the producer wrapper once and list its names.

### Measured (2026-10-14)

| Tree | Result |
|---|---|
| Research samples (`contracts/`, `es/`: TS, Python, Java, C#, SQL; bad and good) | 0.3 s, 0 rg errors. **Every Node and SQL BAD line surfaces in its §**: `JSON.parse(message.value…)` and `json.loads(msg.value())` (2a), `default: assertNever(evt.type)` (3a), `.strict()`, `extra="forbid"`, `new ObjectMapper()`, `UnmappedMemberHandling.Disallow` (3b, 4 of 4; the Java file's two tolerance markers in 3c), `appendToStream(s, e)` and `{ streamState: ANY }` (6a, with `BigInt(rev)` and `NO_STREAM` quiet), the events table with no `UNIQUE(stream, version)` (7a; the one with it quiet), the increment and the separate checkpoint (8a), `ORDER BY created_at` (9d's count). `stacks.md` hits the Marten read-decide-append, `StreamState.Any` and `current_version=StreamState.ANY` with `FetchForWriting` quiet. The samples' topic pair sits in **one** file, so 5a — which counts files — stays quiet by design |
| Seeded tree (20 files, five schema formats with a `--contract-base` copy, plus the same tree as a two-commit git repo and a shallow clone of it) | Contract lane: buf **100** on a reused field number; json-schema-diff backward **BREAKING** on a required field added, forward PASS, plus PARTIAL (the schema has an enum); `asyncapi -t breaking` **PASS** with **4** unclassified payload changes for the same required field; oasdiff **BREAKING** only at `--fail-on WARN` (a webhook property removed); the Avro field added with a default PASS both ways (precedent 28); a deleted schema (1d); `amount: total / 100` in a producer (4). The git base gives the same verdicts; the shallow clone prints `BASE NOT-RUN`. Unparseable heads print **NOT-RUN** (json-schema-diff `VError`, asyncapi `ValidationError`) — never PASS. Greps: the near-twin `order.placed` / `orders.placed` and the produced-never-consumed / consumed-never-produced pair (5a, 5b), `OrderPlacedEvent` declared in two modules (5c), `json.loads(body)` in a Celery task (2b), `apply()` that mails (9a), a snapshot with no version (9b), a Prisma event model with no `@@unique` (the one with it quiet), an ORM entity, a SQL table cleared by a later unique index (7b), command → 303 and mutate → navigate (10a, 10b). The `.strict()` request DTO and the `streamState: ANY` audit stream print, for part A and precedent 29 to clear |
| `benchmarks/fixtures/*` (18 fixtures) | 0 rg errors; detector lines in 6. `quality-mix`: 2b the order-import consumer's `JSON.parse(message.body) as ImportedOrder` — its QM-TY-01 seed, code-quality `type-safety-scan` § 1's (routed). `slow-marketplace`: 2c two Redis pub/sub `JSON.parse(message) as PriceEvent` (the same owner's measured shape, routed); 5b one name. `brittle-suite`: 2b a webhook callback's `json.loads(body)` (HTTP → backend, part A) and 5b a queue registered with no worker in the fixture. `ci-defects`: 5b two worker queues whose producers name them through a constant. `mobile-defects`: 2a a WebView message (not a broker, part A). 12 fixtures print nothing. A first draft's 2b counted `JSON.parse(` itself as a validator and missed the QM-TY-01 file; fixed before shipping |
| Real repository (NestJS 11 + Fastify, MikroORM, BullMQ, zod 4.5.4 and class-validator 0.15.1 in the lockfile — F1L prints them since 2026-10-14 (19 lines; ajv is locked at 6 and 8 and `@grpc/proto-loader` at 0.7 and 0.8, so the importer's entry decides); read-only, about 11,100 files) | Re-run 2026-10-14 on the full tree, a git checkout with the shared contracts package, CI and the lockfile readable: 2.2 s, 0 rg errors. S1–S4 still **0** (the contracts package is zod HTTP schemas, not message schemas) and CI holds no contract gate. Changed: F1 adds the package's zod; 2a 4 parses (the fourth a browser CSP report body: HTTP, part A); 3c 1; **5c 1 → 5** — the report payload now also in the contracts package, plus four chat, support, team-chat and system message types declared in the contracts package (zod-inferred) and again as hand-written interfaces in the web app: HTTP payloads, so part A routes them — the hand-written web copies to code-quality `type-safety-scan` § 1, the server's shape to backend — never a broker row. The rest below is the earlier partial copy (not a git checkout) | 1.8 s, 0 rg errors. S1–S4 **0** → §1 `N/A (no schema files)`, CONTRACT GATE N/A, `BASE NOT-RUN`. 2a **3 → 0**: an outbound carrier-API response and a token payload (not messages, part A), and a Redis pub/sub session event parsed inside a `try` with a hand-written guard that returns `null` on a wrong shape (gate 4). 3a 0, 3b 0, 3c 2 zod `.passthrough()`. 5a **1 → refuted** (a third-party platform's webhook topic named in two connectors — the vendor owns the name); 5b 1 (a websocket event to browsers, part A); 5c 1 (an HTTP report payload declared in the API and the web app → backend). Queue names are shared constants — the GOOD shape — so 5a sees none. ES evidence 0 → §6–§9 N/A; §10 N/A. First-draft noise removed before shipping: 56 `additionalProperties: false` in LLM tool-parameter schemas (3b now reads that key only in schema documents and consumer files), `address`/`channel`/`subject` keys that are data fields (5a), a name split on `:` (`session:ended` read as `ended`), browser DOM event types (5c now counts services), and a SQL column-projection helper that opened §10 |

## The 3,000-hit path

1. **Start from F2, not from the greps.** A repo with schema files is judged by the tool lane first; one tool run per module or document replaces a thousand grep lines. A repo with none prints §1 `N/A` and moves to the consumers.
2. **Intersect** 2a–3b with the consumer files (`CONS`, 2c) and the process types that run them: a parse in a script, a seed or a test fixture is dismissed with one read. Name the directory in the coverage block.
3. **Group by root cause**: one shared deserializer, one base consumer, one envelope type, one projection base class is **one** finding with N sites. Schema findings group by the event, not by the field.
4. **Rank**: events another service or a stored stream depends on first (orders, payments, stock, identity); then events with a replay path; then internal notifications.
5. **Read the top 30 groups**; sample 10 sites per group, and the rest inherit the verdict with the group's id.
6. **Run the bulk oracle once**: V324's replay-old-events corpus over every consumer of the top event; V325 over the top aggregate.
7. **Ledger the rest** as `Live, unreviewed (k groups / n hits — <continuing command>)`. Never mark it `checked`.

## Scans for

Severity assumes a production path. One replica does not make a contract or store row latent (precedent 32).

| § | Classes | Default severity |
|---|---|---|
| 1 | EC-1 breaking schema change (diff lane) | high; BLOCKER when a removed or retyped field is read by a consumer that deploys apart from the producer, or a deleted schema still has subscribers; medium for `BACKWARD` (latest only) on a long-retention topic |
| 2 | EC-2 inbound event not checked against its contract | medium; high when a malformed event crash-loops a partition or persists garbage another service reads |
| 3 | EC-3 intolerant reader | high when the producer deploys independently; medium otherwise; not a row for the service's own internal union or an outbound schema (precedent 28) |
| 4 | EC-4 semantic change with the same type (read-only) | high; never above Unconfirmed without V324's replay corpus |
| 5 | EC-5 name drift; producer-only types; no version or upcaster | medium for a near-twin live on both sides (split brain); low for a duplicated literal; high for a stored event shape changed with no upcaster |
| 6 | ES-1 append without expected version after a read-decide | high; money, stock and credit invariants → security (§ Boundaries) |
| 7 | ES-2 custom event table without UNIQUE(stream, version) | high |
| 8 | ES-3 projection not idempotent; checkpoint outside the projection's transaction | high when someone acts on the figure; medium for a display-only read model |
| 9 | ES-5 side effects in `apply`; snapshot without version | high for an external effect on replay; medium for a snapshot |
| 10 | CN-2 eventual consistency assumed in a UI flow | medium |

### 1. Breaking schema change (EC-1)

```
BAD:   message OrderPlaced { string order_id = 1; string coupon = 2; }   // field 2 was int64 amount_minor: tag reused (buf 100)
       "required": ["orderId", "amountMinor", "currency"]                  // a required field added: every stored event fails the new reader
       {"type":"enum","name":"Status","symbols":["PLACED","PAID","REFUNDED"]}   // old readers have no enum default (SZ2)
       compatibility: BACKWARD on a topic with infinite retention          // latest-only check (SZ3)
       a webhook body losing a property receivers read (oasdiff WARN) · a schema file deleted while consumers subscribe
GOOD:  a new field number and `reserved 2;` · a new optional field with a default · an enum default · BACKWARD_TRANSITIVE / FULL_TRANSITIVE
       a new versioned event type for a breaking shape, both published until every reader moved · the compatibility tool in CI
```
**Read the code:** Which `1t` line, at which exit, against which base? Who reads the event (in this repo, in another, in a replay), and which direction must hold — is the registry mode transitive (S5)? Did the tool parse both sides (a `NOT-RUN` is never a pass)? For AsyncAPI, compare the payloads (`unclassified`) as JSON Schema or read them; for JSON Schema with `PARTIAL`, read the enum and pattern diff by hand. For a TS-only type, would the old consumer still compile against the new one? Is the change fronted by a new event type or an upcaster (§5)? **Finding when** a reader that deploys apart from the writer cannot read an event it will receive or has stored. **Proof (V323):** the format tool exits breaking on base → head before the fix and 0 after (tier T); a fix that adds `--no-error` or sets the registry to `NONE` fails the variant. Writing a provider verification is testing `contract-test`.

### 2. Inbound event not checked against its contract (EC-2)

```
BAD:   const evt = JSON.parse(message.value.toString()); await apply(evt.amount)      // no schema on a topic whose schema is in S3
       evt = json.loads(body); Order.objects.create(**evt)                            // a Celery task writing whatever arrived
       a consumer of an Avro topic reading raw bytes with JSON.parse instead of the registry deserializer
GOOD:  const evt = OrderPlaced.parse(JSON.parse(raw)) · Order.model_validate_json(body) · the registry's AvroDeserializer
       — and a malformed event parked (DLQ, a failed set) instead of thrown back for redelivery
```
**Read the code:** Is this a broker message from another process (part A — an HTTP body, a WebView message, a cache entry is not)? Is the value cast (`as T`) — then code-quality `type-safety-scan` § 1 owns it — or used raw? Does the topic have a schema (S1–S3) that this consumer bypasses? Is there a guard further down (a `typeof` check, a try that returns `null`)? What does a malformed event do: park, skip with a log, or throw and redeliver (→ `messaging-scan` § 3)? **Finding when** a malformed or wrong-version event reaches an effect or a crash loop. **Proof (V324):** feed the malformed fixture to the real handler: parked, not a crash loop, no write.

### 3. Intolerant reader (EC-3)

```
BAD:   switch (evt.type) { …; default: assertNever(evt.type) }   // in a consumer of another team's events
       z.object({...}).strict() / extra="forbid" / UnmappedMemberHandling.Disallow on an INBOUND event
       private final ObjectMapper mapper = new ObjectMapper();     // FAIL_ON_UNKNOWN_PROPERTIES on (SZ6)
       an Avro reader enum with no "default" (3d) · additionalProperties: false on a consumer-side schema
GOOD:  default: log and ack (or park) the unknown type · zod's default strip or .passthrough() · the Spring Boot mapper
       mapper.configure(FAIL_ON_UNKNOWN_PROPERTIES, false) · READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE · an Avro enum default
```
**Read the code:** Is the strict schema applied to what this service **receives** from another producer, or to what it sends or owns (precedent 28)? Is the `assertNever` over the service's own compile-time union, or over a type read from the network? Is the mapper the framework's configured bean or a hand-made one? Does a tolerance marker (3c) configure the same mapper? **Finding when** a producer adding a field, an enum symbol or an event type makes this consumer throw. **Proof (V324):** the extra-field and unknown-type fixtures are acked or parked by policy without a throw.

### 4. Semantic change with the same type (EC-4)

```
BAD:   - JSON.stringify({ id, amount: total })   + JSON.stringify({ id, amount: total / 100 })   // minor → major units, same field, same type
       durationS now carrying milliseconds · a nullable field whose null now means "unknown" instead of "none"
GOOD:  a new field (amountMinor) or a new event version, the old one kept until readers moved · units in the field name
```
**Read the code:** Line 4 prints changed lines with a scale factor, a rounding call or a unit-suffixed field, in schemas and in files that name a topic: did the meaning of a field another reader uses change? Money as a float in the payload is backend `wire-format-scan` § 5's — cite it. Every compatibility tool passes this class (the seeded `amount` change passed buf, json-schema-diff and avsc). **Finding when** an existing reader computes a wrong value from an event it reads correctly. **Proof (V324, replay corpus):** deserialise captured events of each version through the current consumer: same meaning, 0 failures.

### 5. Name drift, producer-only types, no version or upcaster (EC-5)

```
BAD:   topic: 'orders.placed' in three files, 'order.placed' in one        // a rename splits producers from consumers
       interface OrderPlacedEvent { … } re-declared in billing with a different field
       a stored event class changed in the diff, no schemaVersion in the envelope, no upcaster registered
       an envelope with no producer-set event id, so every consumer reserves on a broker id that changes on redelivery
GOOD:  one constant, a generated client or the AsyncAPI document · one shared schema package · schemaVersion + an upcaster per old version
       a producer-set event id in the envelope (CloudEvents `id`, `eventId`) minted once per fact
```
**Read the code:** For 5a, is each literal the same channel, and is one side a typo or a stale rename? Is the name owned by another vendor (part A)? For 5b, where is the other side — another repo, a CDC topic, a test only? For 5c, do the declarations' fields differ, and is the type an event at all? A contracts-package type re-declared by hand in a client app is HTTP type drift → code-quality `type-safety-scan` § 1 (§ Boundaries), not only backend. For 5d, has the envelope already changed shape (the §1 diff, a branch on `'field' in evt`), and is there an upcaster for each stored version? Does the envelope carry an event id the producer sets once — the contract half of `messaging-scan` § 2's unstable reserve id (rows ID1–ID9)? **Finding when** a producer and a consumer that must meet do not, or a stored event can no longer be read. **Proof (V324):** the replay-old-events corpus through the current consumer; for a rename, publish on the producer's name and assert the consumer receives it.

### 6. Append without expected version after a read-decide (ES-1)

```
BAD:   const s = await load(id); if (s.stock < qty) throw …; await client.appendToStream(`order-${id}`, evts)   // default ANY (EV1)
       await client.appendToStream(stream, evts, { streamState: ANY })   after a decision on loaded state
GOOD:  await client.appendToStream(stream, evts, { streamState: revisionLoaded })   // WrongExpectedVersionError → reload, re-decide
       Marten FetchForWriting<T>(id) (EV3; stacks.md) · the store's optimistic API with the version read
```
**Read the code:** Did the code read the stream and branch on its state before this append? Then which revision did it read, and is it passed? Is the stream append-only facts with no invariant — audit, telemetry (precedent 29)? Is a lock or a single writer per stream the control instead (name it)? A money, stock or credit invariant goes to security (§ Boundaries). **Finding when** two deciders working from the same version can both append. **Proof (V325):** two deciders released by a barrier → one append succeeds, the other gets `WrongExpectedVersion`.

### 7. Custom event table without UNIQUE(stream, version) (ES-2)

```
BAD:   CREATE TABLE order_events (id bigserial primary key, stream_id uuid not null, version int not null, data jsonb)
       model OrderEvent { id Int @id; streamId String; version Int }     // no @@unique([streamId, version])
GOOD:  UNIQUE (stream_id, version) or PRIMARY KEY (stream_id, version) · @@unique([streamId, version]) · @Unique({ properties: ['streamId', 'version'] })
```
**Read the code:** Does a later migration add the unique index (7b)? Is the table written by a library store that owns the constraint (Marten, the Axon JPA store, the Python `eventsourcing` library — precedent 30)? Is it an event *log* (webhook deliveries, an outbox) rather than a stream store? Is the version computed from a read (`max(version) + 1`)? **Finding when** two concurrent appends of version n+1 both commit. **Proof (V325, SQL layer):** two transactions insert `(stream, n+1)` → the second fails with a unique violation after the fix.

### 8. Projection not idempotent; checkpoint outside its transaction (ES-3)

```
BAD:   await db.query('UPDATE stats SET orders = orders + 1'); await checkpoints.save('stats', e.position)   // two commits
       the checkpoint saved before the projection write · INSERT INTO order_view … with no conflict guard
GOOD:  the read-model write and the checkpoint in one transaction · an upsert keyed by event id or position
       (ON CONFLICT DO NOTHING, WHERE last_position < :pos) · a rebuild-and-swap projection
```
**Read the code:** Are the projection write and the checkpoint in one transaction on one store? Is the write idempotent per event (keyed by event id, position-guarded, SET not INCREMENT)? Does a redelivery or a restart from the last checkpoint re-apply the event? Is it a full rebuild-and-swap (cleared for the rebuild path)? A projection fed by a broker with no reserve at all is `messaging-scan` § 2's. **Finding when** a redelivery or a crash between the two writes changes the read model. **Proof (V326):** inject a throw between the write and the checkpoint, restart → the read model unchanged; redeliver one event → unchanged.

### 9. Side effects in `apply`; snapshot without version (ES-5)

```
BAD:   apply(e: OrderShipped) { this.status = 'shipped'; this.mailer.sendShipped(this.id) }   // replay mails every customer again
       a snapshot row { id, state } with no schema version, loaded and returned without applying the tail
GOOD:  apply mutates state only; a subscriber with its own reserve sends the mail · a snapshot carrying schemaVersion and streamVersion,
       discarded on a mismatch, then the events after streamVersion applied
```
**Read the code:** Does the method run during rehydration (`loadFromHistory`, an `evolve` fold, an `@EventSourcingHandler`) or only on the command path? Is the I/O a pure in-memory call? Does the snapshot loader compare the version it was written with against the aggregate's current shape, and replay the tail? A daemon running on every replica is performance § 20's (9c); timestamp ordering is `coordination-scan` § 9's (9d). **Finding when** a replay or a rebuild repeats an external effect, or a snapshot of an old shape is used as current state. **Proof (V326):** replay the full stream through the aggregate with the side-effect port mocked to count → 0 sends; load a v1 snapshot into the v2 aggregate → discarded or upcast.

### 10. Eventual consistency assumed in a UI flow (CN-2)

```
BAD:   await this.commandBus.execute(new PlaceOrderCommand(dto)); return res.redirect(303, '/orders')   // the list reads an async projection
       await createOrder.mutateAsync(v); navigate('/orders')   // the loader reads the lagging read model
GOOD:  return the new id and position; the next read waits for it (or reads the write side) · an optimistic UI entry reconciled later
```
**Read the code:** What does the redirect target read — the write model, or a read model fed asynchronously? Does the command return a position or version, and does the read wait on it (10c)? Is the lag observable (a list missing the new row, a stale balance)? A replica lag is database's. **Finding when** the next screen reads a projection that has not caught up and shows the old state as fact. **Proof (V327):** write → redirect → read with the projection paused: the new value appears, or the read waits on the returned position.

## Output

```
event-contract-scan coverage — scope=diff (base: --contract-base=.refract-fixture/contract-base)  stacks=@kurrent/kurrentdb-client ^1.3 range, zod ^4.1, kafkajs ^2.2
Contract inventory: S1 3 (proto 1, avsc 2) · S2 1 · S3 1 · S4 1 · registry BACKWARD (deploy/registry.env:4) · CONTRACT GATE absent
Tools: buf 1.73.0 ran · json-schema-diff 1.0.0 ran (PARTIAL: enum) · asyncapi 6.2.1 ran (4 payload changes unclassified) · oasdiff blocked · avsc ran

Findings: 2 · Unconfirmed: 2 · Hardening: 0

1. schemas/proto/orders/v1/order.proto:6 · base .refract-fixture/contract-base/schemas/proto/orders/v1/order.proto    [report-with-fix]   BLOCKER
   §1 EC-1 — part A: OrderPlaced is consumed by src/consumers/orders.consumer.ts:2 and the billing service (AsyncAPI receive).
   Part B: field 2 reused as `string coupon` (was `int64 amount_minor`); buf breaking exit 100 (SZ1). Fix: `coupon = 4;` and `reserved 2;`.
2. src/es/order.service.ts:41    [report-with-fix]   high
   §6 ES-1 — part A: the stock check reads the stream at :37. Part B: appendToStream with no streamState (default ANY, EV1).
   V325: two deciders behind a barrier both appended (failed); after passing the loaded revision, one WrongExpectedVersion.
3. schemas/asyncapi/orders.yaml:14    [report-flagged]   high
   §1 EC-1 — `currency` added to `required`; asyncapi -t breaking PASS but the change is unclassified; json-schema-diff on the payload
   (backward) BREAKING. Settling test: V323 with the payload extracted, and the reader list from the billing repo.
4. src/orders/orders.service.ts:3    [report-flagged]   high
   §4 EC-4 — `amount: total` → `amount: total / 100`, same field, same type; every tool passed. Settling test: V324 replay corpus.

Verification: 19 candidates → 2 findings · 2 unconfirmed · 9 refuted · 4 routed · 2 live, unreviewed (self-verified)

Class coverage:
  §1  breaking schema change     checked (5 tool runs → 1 finding, 1 unconfirmed; oasdiff blocked (lane not installed: oasdiff@1.33.0))
  §2  unvalidated payload        checked (3 → 1 refuted) · → code-quality type-safety-scan §1 (2)
  §3  intolerant reader          checked (4 → 1 refuted: outbound DTO, precedent 28) · Live, unreviewed (1 group / 2 hits)
  §4  semantic change            checked (4 changed lines → 1 unconfirmed)
  §5  names and versions         checked (5a 1, 5b 2, 5c 1 → 0)
  §6  append without version     checked (2 → 1 finding, 1 refuted: audit stream, precedent 29) · → security logic-race-scan §5 (0)
  §7  stream uniqueness          checked (3 → 1 cleared by a later index, 2 refuted: library store, precedent 30)
  §8  projection idempotency     checked (1 → 0: rebuild-and-swap)
  §9  apply / snapshot           checked (0) · 9c → performance request-path-scan §20 (0) · 9d → coordination-scan §9 (1)
  §10 UI read after write        N/A (no projection or read-model class and no event store)
```

`[report-with-fix]`: the tool or the oracle failed on the unfixed code, and the fix is local to the project's schema, envelope, store call or projection. `[report-flagged]`: one link is unproven (a tool `blocked` or `NOT-RUN`, a reader in another repo, no replay corpus); the row names it and the settling test. The coverage block lists **all ten** §s on every run: `checked (k → m)`, `N/A (<reason>)`, `NOT-RUN (no base)`, `blocked (<tool>)`, `→ <owner> (k)` or `Live, unreviewed (k groups / n hits)`. A missing line is printed by `/ds-audit` as `not run`.

## False positives / precedents

The verifier reads [`precedents/distributed.md`](../../../../snippets/precedents/distributed.md) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:
- **§ 28** additive, optional changes are compatible (an Avro field with a default, a new protobuf field number, a JSON field for tolerant readers); a strict reader on an **outbound** DTO is not an intolerant reader. **§ 29** `streamState: ANY` is correct for append-only facts with no invariant. **§ 30** library event stores own the uniqueness constraint.
- **§ 13** `WHERE id > $last` over one serial writer (the cursor itself is `dual-write-scan` § 6's). **§ 31** timestamps are fine for display and audit. **§ 32** one replica does not make a store row latent. **§ 33** a mocked store or bus proves nothing about concurrency. **§ 34** a comment saying "backward compatible", "consumers ignore unknown fields" or "the registry checks it" is a pointer: read the registry mode, the mapper and the consumer.

Shapes this skill must not report: a `.strict()` request DTO or an LLM tool schema; `assertNever` over the service's own union; a topic a third-party vendor names; a schema change no reader receives; a tool `PASS` reported as proof while its `PARTIAL` line is unread; `ORDER BY created_at, id` within one stream.

## When to run

- On any diff that touches a schema file, an event type, an envelope, a producer mapping, an event-store append, an event table migration or a projection — `/ds-audit`'s "a schema file, an event type, an event-store append, a projection" signal. Pass `--contract-base=<dir>` when the repo has no base branch (a fixture, an export, a vendored copy).
- On every whole-repo `/ds-audit`, reporting all ten §s; before changing a registry compatibility mode, renaming a topic, adding a field to a long-retention event, or introducing snapshots.
- After `/add-event-handler`: its Phase 6 runs V324 from the same oracle file.

## Halt conditions

- **Do not report a high row as confirmed until a separate verifier has refuted it.** That verifier is not the run that found it, gets part A, part B, the base, the tool's exit and first line, the `broker-semantics.md` row and the version lines — never this run's reasoning — and passes the row through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with `precedents/distributed.md`. Under `/ds-audit` that is its verification phase; standalone, apply the protocol as a second pass and tag the rows `self-verified`. List the rest as Unconfirmed, Hardening, Pre-existing or Refuted; never drop them.
- **Never print PASS for a tool that did not parse both sides.** A non-zero exit without the tool's breaking marker, a `VError` or `ValidationError`, a load failure, a misaligned buf root or a missing base is `NOT-RUN` with the first line; with no base at all §1 and §4 are `NOT-RUN (no base)`.
- **Never run the AsyncAPI CLI with its metrics on** (`CI=true` and `ASYNCAPI_METRICS_CONFIG_PATH`, as in the block), never install `oasdiff` from npm, and never call a schema registry, broker or event store outside a scratch container — `test-compatibility` against a shared registry registers nothing, but its credentials are the user's to give.
- **Halt on any finding missing its anchors:** the schema or append `file:line`, the base, the reader or decider, the row, the fix in the project's own primitive.
- **No fix may make the check pass instead of the contract:** `--no-error`, a registry set to `NONE`, `.passthrough()` on a schema whose strictness was a decision, or a version gate removed. Changing a registry compatibility mode is a STOP for `/ds-fix`.
- **Do not grade a class another pack owns** (§ Boundaries); route it with a count. **Do not print "contracts look compatible"** without the coverage block; a § whose grep did not run is `blocked`, never `checked`.

## Related

- `/ds-audit` dispatches this skill and folds its coverage block into the Contracts and Event sourcing ledger rows and the CONTRACT GATE; `/ds-fix` closes findings with the failing-first tool run or oracle, the fix in the project's schema, envelope, store call or projection, a re-run of this detector and the sibling sweep.
- `messaging-scan` (poison, consumer reserve), `dual-write-scan` (the outbox, gap-blind cursors), `coordination-scan` (timestamp ordering) and `workflow-scan` (history versioning) are the neighbours named in § Boundaries; `@event-sourcing-architect` judges these rows.
- code-quality `type-safety-scan` § 1, backend `http-contract-scan` § 10, `job-lifecycle-scan` § 3, `wire-format-scan` § 5 and `validation-boundary-scan` § 6, § 8, testing `contract-test`, security `logic-race-scan` § 5, performance `request-path-scan` § 20 and database `connection-config-scan` § 8, when those packs are installed.

## Sources

Format and store facts are rows of `broker-semantics.md` (SZ1–SZ13, EV1–EV3; read 2026-10-14 from buf.build *Breaking rules*, the Avro 1.12.0 specification, Confluent Platform 8.3 *Schema evolution*, the AsyncAPI CLI `docs/usage.md`, the json-schema-diff README, the oasdiff README, library docs for Jackson, System.Text.Json, pydantic and zod, the KurrentDB Node client source and martendb.io). Pact's message-pact API and `can-i-deploy` are the distributed-systems research notes B § 5.0 (pact-js 17.1.4, 2026-10-14). The tool-lane exit codes and traps were measured in this build on 2026-10-14 against buf 1.73.0, json-schema-diff 1.0.0 (and its `KEYWORDS.md`), @asyncapi/cli 6.2.1 (and its metrics code path), oasdiff 1.33.0 (the checksum-verified GitHub release) and avsc 5.7.9, on scratch schemas only. Grep literals are the research notes B § 5.1 (C1–C8) and § 6.1 (E1–E9), tested on the research samples, re-measured here with the fixtures and a read-only real repository. Paraphrased; no text reproduced beyond identifiers.

Files in this skill

  • SKILL.md74.3 KB
  • stacks.md8.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.