Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
# Schema-registry completion
**Package**: `@suaveplan/schema-registry`
**Status**: PROPOSED
**Type**: feature
## Why
Genesis ships only a thin in-memory `SchemaRegistry` (`createRegistry`, `register` / `getLatest` / `getVersion` / `getAll` / `listSubjects` / `deleteSubject` / `setCompatibility` / `checkCompatibility` / `clear`) plus the `checkCompatibility` predicate and a four-code error registry. The legacy `packages/universal/schema-registry/src` shipped a full enterprise schema-registry surface with seven additional capabilities that genesis silently dropped:
1. **Transitive compatibility** — `BACKWARD_TRANSITIVE` / `FORWARD_TRANSITIVE` / `FULL_TRANSITIVE` modes that check a candidate against ALL prior versions, not just the latest. Legacy `src/compatibility/transitive-checker.ts` (L1-L100) ships `checkTransitiveAgainstAll`, `buildTransitiveReport`, `TransitiveReport`, `TransitiveVersionResult`, `isTransitiveMode`, `stripTransitive`. Without these, schema-evolution policies that mandate "every old consumer must keep working" cannot be enforced — a real concern for event-sourced systems and message buses.
2. **Schema diff** — `diffSchemas(a, b)` → `SchemaDiffResult` and `formatDiffReport(result)` → human string, plus the `SchemaDiffChange` and `DiffChangeType` types. Legacy `src/diff/schema-differ.ts` (L1-L175). Without diff, callers cannot surface "what specifically broke" to ops users.
3. **`SchemaStoreAdapter` interface** — the contract that lets the registry persist beyond process memory. Legacy `src/types/registry.types.ts` (L82-L105) declares 8 methods; genesis has no analogous abstraction.
4. **`MemorySchemaStore`** — in-memory implementation of `SchemaStoreAdapter`. Legacy ships this implicitly inside the registry; genesis needs it as a standalone module so consumers can swap stores.
5. **`DatabaseSchemaStore`** — `DatabaseAdapter`-backed implementation. Legacy `src/store/database-store.ts` (L1-L147). Without it, the genesis registry is process-local.
6. **Canonicalisation utilities** — `canonicalizeSchema`, `computeFingerprint`, `djb2Hash`, `flattenFields`, `isOptionalOrHasDefault`. Legacy `src/utils/utils.ts` (L1-L70). The registry needs deterministic fingerprints to deduplicate identical re-registrations and to support hash-based subject indexing.
7. **`SubjectConfig`** — per-subject configuration object including compatibility mode plus metadata. Legacy `src/types/registry.types.ts` (L65-L68) ships a minimal version; the genesis registry hardcodes the compat level on `SubjectState` with no metadata or persisted version cap.
The genesis-original behaviour the registry already ships (auto-incrementing version, `INVALID_SCHEMA` validation, OTel `register.total` counter and `check.duration_ms` histogram) is preserved. This change is purely additive on the existing `SchemaRegistry` surface — no current export is removed or renamed.
## What changes
- ADDED: `CompatibilityLevel` widened to `"BACKWARD" | "FORWARD" | "FULL" | "NONE" | "BACKWARD_TRANSITIVE" | "FORWARD_TRANSITIVE" | "FULL_TRANSITIVE"`. Existing four levels keep their meaning; the three new modes activate per-subject transitive checking.
- ADDED: `checkTransitiveCompatibility(history: readonly SchemaDefinition[], candidate: unknown, mode: CompatibilityLevel)` → `CompatibilityResult` — convenience entry point.
- ADDED: `buildTransitiveReport(history, candidate, mode)` → `TransitiveReport` — verbose per-version compatibility breakdown.
- ADDED: `TransitiveReport` interface — `{ compatible: boolean; mode: CompatibilityLevel; versionResults: TransitiveVersionResult[]; totalVersions: number; failedVersions: number }`.
- ADDED: `TransitiveVersionResult` interface — `{ version: number; compatible: boolean; errors: readonly string[] }`.
- ADDED: `isTransitiveMode(mode)` and `stripTransitive(mode)` helper functions.
- ADDED: `diffSchemas(oldSchema, newSchema)` → `SchemaDiffResult` — structural diff identifying added / removed / modified fields and flagging breaking changes.
- ADDED: `formatDiffReport(result)` → human-readable string — counts plus per-change `+ / - / ~` rendering with `[BREAKING]` annotation.
- ADDED: `SchemaDiffChange` interface — `{ changeType: DiffChangeType; path: string; breaking: boolean; description: string; oldField?: SchemaField; newField?: SchemaField }`.
- ADDED: `SchemaDiffResult` interface — `{ changes: readonly SchemaDiffChange[]; hasBreakingChanges: boolean; summary: string }`.
- ADDED: `DiffChangeType` union — `"added" | "removed" | "modified"`.
- ADDED: `SchemaField` interface — `{ name: string; type: string; required?: boolean; defaultValue?: unknown; fields?: readonly SchemaField[] }`. Used by the diff and canonicalisation utilities. (Legacy registry already had this; genesis dropped it.)
- ADDED: `SchemaStoreAdapter` interface — `getVersions(subject)`, `getVersion(subject, version)`, `getLatest(subject)`, `putVersion(version)`, `getSubjects()`, `hasSubject(subject)`, `deleteSubject(subject)`, `getConfig(subject)`, `setConfig(subject, config)`. All async.
- ADDED: `MemorySchemaStore` class implementing `SchemaStoreAdapter` against an in-memory `Map`.
- ADDED: `DatabaseSchemaStore<TAdapter extends DatabaseAdapter>` class implementing `SchemaStoreAdapter` over `@suaveplan/db`'s `DatabaseAdapter`. Constructor takes `(db, tableName?, configTableName?)` defaulting to `"schema_versions"` / `"schema_configs"`.
- ADDED: `canonicalizeSchema(schema)` → stable JSON string with fields sorted at every level.
- ADDED: `computeFingerprint(schema)` → 8-hex-char djb2 hash of the canonical form.
- ADDED: `djb2Hash(input)` → 8-hex-char hash. Exported as a building block.
- ADDED: `flattenFields(fields, prefix?)` → `Map<string, SchemaField>` keyed by dot-notation paths.
- ADDED: `isOptionalOrHasDefault(field)` → boolean.
- ADDED: `SubjectConfig` interface — `{ compatibility: CompatibilityLevel; maxVersions?: number; metadata?: Record<string, string> }`. Replaces the inline-on-`SubjectState` representation.
- MODIFIED: `SchemaRegistry` constructor — accepts an optional `store?: SchemaStoreAdapter`. When supplied, persistence routes through the store; otherwise the existing in-process `Map` path is used. The synchronous public API is preserved when no store is supplied; when a store IS supplied, an alternative async surface (`registerAsync`, `getLatestAsync`, `getVersionAsync`, `getAllAsync`, `listSubjectsAsync`, `deleteSubjectAsync`, `checkCompatibilityAsync`) is exposed alongside the existing sync methods (which throw if a store is configured but the call is sync).
- MODIFIED: `SchemaDefinition` — `metadata?: Record<string, string>` already supported; ADDED a non-breaking `fingerprint?: string` field that, when present, equals `computeFingerprint(schema as SchemaDefinition)`.
- ADDED: error code `SCHEMA_REGISTRY.STORE_FAILURE` — thrown when the underlying `SchemaStoreAdapter` fails (network, DB).
- ADDED: error code `SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE` — thrown when an invalid mode string is supplied.
## Out of scope
- **Confluent Schema Registry HTTP client** — the legacy package never shipped one; the integration with Confluent's REST API is a separate pending change tracked as `schema-registry-confluent-adapter`. Reason for splitting: the Confluent contract is large (subjects, configs, deletes, soft/hard deletes, modes, mode reset, schema-by-id lookup, version-by-schema lookup), authentication varies (basic/bearer/mTLS), and pulling it in here would dilute review of the much smaller transitive + diff + store surface.
- **Avro / Protobuf / JSON-Schema specific format adapters** — `SchemaFormat` already includes `"json-schema" | "zod" | "avro" | "custom"` in genesis. This change ships only the internal Zod-described `SchemaDefinition` shape that drives the diff and compatibility engines; semantic adapters per format land in follow-up changes (`schema-registry-avro-adapter`, `schema-registry-protobuf-adapter`). Reason: each format has bespoke compatibility semantics — Avro reader/writer schema rules differ from JSON Schema's `$ref` resolution.
- **Webhook notifications on schema change** — the legacy package did not ship them; any `register` → fan-out integration is delegated to `@suaveplan/webhook-client` by composition.
- **Schema validation (running data through a schema)** — `@suaveplan/validation` is the canonical home; the registry stores schemas, the validation package validates payloads against them.
- **`zod`-as-`SchemaDefinition` autodiff** — the diff engine works on `SchemaDefinition` (`{ type, fields }`) per the legacy contract. Translating a Zod schema into `SchemaDefinition` is a follow-up; for now consumers either store JSON-Schema-shaped definitions or supply a custom translator.
## Source mapping
This change derives from the legacy `packages/universal/schema-registry/src` tree at `../suaveplan/`. No `enterprise/schema-registry.md` per-feature audit doc exists; source mapping cites the upstream files directly.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `checkTransitiveCompatibility` (= `checkTransitiveAgainstAll`) | `packages/universal/schema-registry/src/compatibility/transitive-checker.ts` | L18-L35 | Renamed for symmetry with `checkCompatibility`; behaviour verbatim |
| `buildTransitiveReport` | `packages/universal/schema-registry/src/compatibility/transitive-checker.ts` | L58-L84 | Verbatim |
| `TransitiveReport` interface | `packages/universal/schema-registry/src/compatibility/transitive-checker.ts` | L94-L100 | Verbatim shape; widen `mode` to genesis `CompatibilityLevel` |
| `TransitiveVersionResult` interface | `packages/universal/schema-registry/src/compatibility/transitive-checker.ts` | L87-L91 | Verbatim |
| `isTransitiveMode` / `stripTransitive` helpers | `packages/universal/schema-registry/src/compatibility/transitive-checker.ts` | L40-L52 | Verbatim |
| `diffSchemas` | `packages/universal/schema-registry/src/diff/schema-differ.ts` | L16-L40 | Verbatim |
| `formatDiffReport` | `packages/universal/schema-registry/src/diff/schema-differ.ts` | L45-L64 | Verbatim |
| `SchemaDiffChange` interface | `packages/universal/schema-registry/src/types/registry.types.ts` | L118-L132 | Verbatim |
| `SchemaDiffResult` interface | `packages/universal/schema-registry/src/types/registry.types.ts` | L134-L142 | Verbatim |
| `DiffChangeType` union | `packages/universal/schema-registry/src/types/registry.types.ts` | L116 | Verbatim |
| `SchemaField` interface | `packages/universal/schema-registry/src/types/registry.types.ts` | L15-L26 | Verbatim |
| `SchemaStoreAdapter` interface | `packages/universal/schema-registry/src/types/registry.types.ts` | L82-L105 | Verbatim shape; widen mode types to genesis `CompatibilityLevel` |
| `DatabaseSchemaStore` class | `packages/universal/schema-registry/src/store/database-store.ts` | L1-L147 | Re-target `DatabaseAdapter` import to `@suaveplan/db`'s `DatabaseAdapter` (legacy used an inline interface) |
| `canonicalizeSchema` / `computeFingerprint` / `djb2Hash` / `flattenFields` / `isOptionalOrHasDefault` | `packages/universal/schema-registry/src/utils/utils.ts` | L1-L70 | Verbatim |
| `SubjectConfig` (`compatibility`, `maxVersions?`, `metadata?`) | `packages/universal/schema-registry/src/types/registry.types.ts` | L65-L68 | Extended: legacy ships only `compatibility`; genesis adds `maxVersions` and `metadata` (already partially modelled as constructor option). |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `MemorySchemaStore` standalone class | Legacy ships the in-memory case implicitly inside the registry; genesis exposes it as a standalone, importable module so consumers can swap stores without re-wiring. Symmetric with `DatabaseSchemaStore`. |
| `SchemaRegistry.{registerAsync, getLatestAsync, ...}` async surface | Legacy registry is sync; legacy `SchemaStoreAdapter` is async. Bridging requires either making the whole API async (BREAKING) or shipping a parallel async surface. Genesis chooses the additive route to preserve the existing sync API for in-memory users. |
| `SCHEMA_REGISTRY.STORE_FAILURE` and `SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE` codes | Genesis-wide policy — every thrown error routes through `@suaveplan/error` (CLAUDE.md §Workflow rule 2). |
| `SchemaDefinition.fingerprint?` field | Required for de-duplication when an identical schema is re-registered; without it, every retry creates a new version. The fingerprint is computed by the registry at register-time using `computeFingerprint`; consumers do not supply it. |
## Verification approach
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
- `packages/core/schema-registry/src/core/transitive/transitive.test.ts` — covers `checkTransitiveCompatibility` against multi-version histories
- `packages/core/schema-registry/src/core/transitive/transitive.spec-conformance.test.ts` — pins `isTransitiveMode` / `stripTransitive` exhaustively over the seven `CompatibilityLevel` values
- `packages/core/schema-registry/src/core/diff/diff.test.ts` — covers `diffSchemas` for added / removed / modified / type-changed cases; `formatDiffReport` rendering
- `packages/core/schema-registry/src/core/diff/diff.spec-conformance.test.ts` — property tests `diffSchemas(a, a) === { changes: [], hasBreakingChanges: false }` and `formatDiffReport(diff(a, a)) === "No changes detected."`
- `packages/core/schema-registry/src/store/memory/memory.test.ts` — covers `MemorySchemaStore` round-trips, subject delete, config get/set
- `packages/core/schema-registry/src/store/memory/memory.spec-conformance.test.ts` — pins the `SchemaStoreAdapter` contract
- `packages/core/schema-registry/src/store/database/database.test.ts` — covers `DatabaseSchemaStore` against an in-memory `DatabaseAdapter` stub
- `packages/core/schema-registry/src/store/database/database.spec-conformance.test.ts` — pins the same contract; verifies `STORE_FAILURE` is raised on adapter exception
- `packages/core/schema-registry/src/utils/canonical/canonical.test.ts` — covers `canonicalizeSchema`, `computeFingerprint`, `djb2Hash`, `flattenFields`, `isOptionalOrHasDefault`
- `packages/core/schema-registry/src/utils/canonical/canonical.spec-conformance.test.ts` — `fast-check` property `canonicalizeSchema(a) === canonicalizeSchema(shuffleFields(a))` (canonicalisation is order-independent)
- `packages/core/schema-registry/src/core/registry/schema-registry.test.ts` — extended to cover the async surface and store wiring
- `packages/core/schema-registry/src/core/registry/schema-registry.spec-conformance.test.ts` — pins the `UNKNOWN_COMPATIBILITY_MODE` and `STORE_FAILURE` error contracts; pins the `SchemaDefinition.fingerprint` invariant (re-register identical schema produces same fingerprint and triggers de-dup)
## Migration
This is an additive change to the `SchemaRegistry` constructor and `CompatibilityLevel` union. No public API is removed; existing in-memory consumers continue to work without modification.
For consumers that want to switch to a persisted store:
- **Old behavior:** `new SchemaRegistry({ defaultCompatibility: "BACKWARD" })` was process-local.
- **New behavior:** `new SchemaRegistry({ defaultCompatibility: "BACKWARD", store: new DatabaseSchemaStore(dbAdapter) })` persists to the supplied store. The async surface (`registerAsync`, `getLatestAsync`, ...) replaces the sync one; calling sync methods on a store-backed registry throws `SCHEMA_REGISTRY.STORE_FAILURE` with `context.reason === "sync_call_on_async_store"`.
- **Search command consumers should run:** `grep -rln "new SchemaRegistry\|createRegistry" packages/ src/`.
- **Recommended replacement:** for in-memory users, no change. For persisted users, switch construction to pass `store` and migrate call sites to the `*Async` variants.
For consumers using `CompatibilityLevel`:
- **Old behavior:** the union was `"BACKWARD" | "FORWARD" | "FULL" | "NONE"`.
- **New behavior:** the union also includes `"BACKWARD_TRANSITIVE" | "FORWARD_TRANSITIVE" | "FULL_TRANSITIVE"`. Exhaustive `switch` statements over `CompatibilityLevel` will surface a TypeScript narrowing miss until consumers add the three new arms (or fall through them to the corresponding non-transitive case).
- **Search command:** `grep -rln "CompatibilityLevel\|defaultCompatibility" packages/ src/`.
- **Recommended replacement:** in any exhaustive `switch`, fall through `BACKWARD_TRANSITIVE` to the `BACKWARD` arm; same for `FORWARD_TRANSITIVE` / `FULL_TRANSITIVE`. The transitive variants only differ at register-time (check against all versions vs. latest).
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/schema-registry` to enumerate. From `grep -rl "@suaveplan/schema-registry" /home/ubuntu/web/genesis/packages/ --include="package.json"`:
- `@suaveplan/events-schema` — [x] tests run / [x] docs reviewed / [ ] no action needed. The events-schema package depends on the registry to track event-schema versions; it currently uses the in-memory path. After this change it can opt into transitive compatibility (event consumers commonly span multiple schema versions in flight) and persisted storage (event schemas should outlive process restarts). Tests must verify the package keeps building and passing with the new union shape.
For all other monorepo packages: no action — they do not depend on `@suaveplan/schema-registry`. This change is additive only.

View File

@@ -0,0 +1,253 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: schema-registry (schema-registry-completion)
**Package**: `@suaveplan/schema-registry`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Transitive compatibility checking against full version history
The package SHALL expose `checkTransitiveCompatibility(history, candidate, mode)` that, given an ordered list of prior schema versions, validates the candidate against EVERY prior version under the supplied transitive `CompatibilityLevel` (`BACKWARD_TRANSITIVE` / `FORWARD_TRANSITIVE` / `FULL_TRANSITIVE`). It SHALL also expose `buildTransitiveReport(history, candidate, mode)` that returns a per-version breakdown including pass/fail status and per-version errors, plus the helper functions `isTransitiveMode(mode)` and `stripTransitive(mode)`. Errors collected from individual versions SHALL be prefixed with `[v<n>]` to make their origin explicit.
#### Scenario: BACKWARD_TRANSITIVE rejects when ANY old version disagrees
- **WHEN** the history has versions v1, v2, v3 and the candidate is backward-compatible with v3 but NOT with v1
- **THEN** `checkTransitiveCompatibility(history, candidate, "BACKWARD_TRANSITIVE")` returns `{ compatible: false, errors: [<some entries prefixed with "[v1] ">], mode: "BACKWARD_TRANSITIVE" }`
#### Scenario: BACKWARD_TRANSITIVE accepts when every old version agrees
- **WHEN** the candidate is backward-compatible with v1, v2, AND v3
- **THEN** the result is `{ compatible: true, errors: [], mode: "BACKWARD_TRANSITIVE" }`
#### Scenario: buildTransitiveReport surfaces per-version pass/fail
- **WHEN** `buildTransitiveReport` is called against a 3-version history where v1 fails and v2/v3 pass
- **THEN** the result has `compatible: false`, `totalVersions: 3`, `failedVersions: 1`, and `versionResults[0].compatible === false` (with `errors.length > 0`), `versionResults[1].compatible === true`, `versionResults[2].compatible === true`
#### Scenario: isTransitiveMode discriminates the three transitive levels
- **WHEN** `isTransitiveMode` is called with each of the seven `CompatibilityLevel` values
- **THEN** it returns `true` for `BACKWARD_TRANSITIVE`, `FORWARD_TRANSITIVE`, `FULL_TRANSITIVE`; `false` otherwise
#### Scenario: stripTransitive maps to the corresponding non-transitive level
- **WHEN** `stripTransitive` is called
- **THEN** `BACKWARD_TRANSITIVE → "BACKWARD"`, `FORWARD_TRANSITIVE → "FORWARD"`, `FULL_TRANSITIVE → "FULL"`, and the four non-transitive values pass through unchanged
#### Scenario: SchemaRegistry register routes through transitive checker for transitive subjects
- **WHEN** a subject's compatibility is set to `FULL_TRANSITIVE` and a candidate is registered against a multi-version history
- **THEN** the registry calls `checkTransitiveCompatibility(state.versions, candidate, "FULL_TRANSITIVE")` rather than `checkCompatibility(candidate, latest, "FULL")`
**Verified by:** `packages/core/schema-registry/src/core/transitive/transitive.test.ts::"BACKWARD_TRANSITIVE rejects when an older version disagrees"` AND `packages/core/schema-registry/src/core/transitive/transitive.test.ts::"BACKWARD_TRANSITIVE accepts when every old version agrees"` AND `packages/core/schema-registry/src/core/transitive/transitive.test.ts::"buildTransitiveReport surfaces per-version pass/fail"` AND `packages/core/schema-registry/src/core/transitive/transitive.spec-conformance.test.ts::"isTransitiveMode discriminates the three transitive levels exhaustively"` AND `packages/core/schema-registry/src/core/transitive/transitive.spec-conformance.test.ts::"stripTransitive maps to the corresponding non-transitive level"` AND `packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"register routes through transitive checker for FULL_TRANSITIVE subject"`
---
### Requirement: Schema diff engine
The package SHALL expose `diffSchemas(oldSchema, newSchema)` returning a `SchemaDiffResult` that enumerates every added / removed / modified field with dot-notation paths and breaking-change flags, plus `formatDiffReport(result)` returning a human-readable summary. The diff engine SHALL detect: schema-type changes (top-level `type` field), removed fields (breaking unless the old field had a default or was optional), added fields (breaking when added as required), modified fields (type change is always breaking; required-flag change is breaking only when becoming required). The result SHALL include a `summary` string of the form `"<n> added, <m> removed, <k> modified[ (has breaking changes)]"`.
#### Scenario: detects added fields and flags requiredness
- **WHEN** the new schema has a field `email` that the old schema lacks, with `required: true`
- **THEN** the result includes `{ changeType: "added", path: "email", breaking: true, ... }`
#### Scenario: detects added optional fields as non-breaking
- **WHEN** the new schema has a field `nickname` with `required: false` (or `defaultValue` present)
- **THEN** the result includes `{ changeType: "added", path: "nickname", breaking: false, ... }`
#### Scenario: detects removed required fields as breaking
- **WHEN** the old schema had a required field `id` that the new schema lacks
- **THEN** the result includes `{ changeType: "removed", path: "id", breaking: true, ... }`
#### Scenario: detects type changes as breaking
- **WHEN** the field `age` changed from `"number"` to `"string"`
- **THEN** the result includes `{ changeType: "modified", path: "age", breaking: true, ... }`
#### Scenario: detects optional→required as breaking, required→optional as non-breaking
- **WHEN** field `phone` changed from optional to required
- **THEN** the change is `breaking: true`
- **WHEN** field `phone` changed from required to optional
- **THEN** the change is `breaking: false`
#### Scenario: top-level type change short-circuits
- **WHEN** the old schema has `type: "json"` and the new one has `type: "avro"`
- **THEN** the result has exactly one change with path `"$type"`, `breaking: true`, and field-level changes are not emitted
#### Scenario: identical schemas produce no changes
- **WHEN** `diffSchemas(a, a)` is called
- **THEN** `result.changes === []`, `hasBreakingChanges === false`, `summary === "No changes"`
#### Scenario: formatDiffReport renders no-changes path
- **WHEN** `formatDiffReport({ changes: [], hasBreakingChanges: false, summary: "No changes" })` is called
- **THEN** the returned string is `"No changes detected."`
#### Scenario: formatDiffReport annotates breaking entries
- **WHEN** the result has at least one breaking change
- **THEN** the rendered report contains `"BREAKING CHANGES DETECTED"` and the per-change line ends with `" [BREAKING]"`
**Verified by:** `packages/core/schema-registry/src/core/diff/diff.test.ts::"detects added required fields as breaking"` AND `packages/core/schema-registry/src/core/diff/diff.test.ts::"detects added optional fields as non-breaking"` AND `packages/core/schema-registry/src/core/diff/diff.test.ts::"detects removed required fields as breaking"` AND `packages/core/schema-registry/src/core/diff/diff.test.ts::"detects type changes as breaking"` AND `packages/core/schema-registry/src/core/diff/diff.test.ts::"detects optional-to-required as breaking and required-to-optional as non-breaking"` AND `packages/core/schema-registry/src/core/diff/diff.test.ts::"top-level type change short-circuits and emits a single $type change"` AND `packages/core/schema-registry/src/core/diff/diff.spec-conformance.test.ts::"diffSchemas of identical schemas produces no changes"` AND `packages/core/schema-registry/src/core/diff/diff.test.ts::"formatDiffReport renders the no-changes path"` AND `packages/core/schema-registry/src/core/diff/diff.test.ts::"formatDiffReport annotates breaking entries"`
---
### Requirement: SchemaStoreAdapter persistence interface
The package SHALL expose a `SchemaStoreAdapter` interface declaring eight async methods: `getVersions(subject)`, `getVersion(subject, version)`, `getLatest(subject)`, `putVersion(version)`, `getSubjects()`, `hasSubject(subject)`, `deleteSubject(subject)` (returning `boolean` — `false` when subject absent), `getConfig(subject)` and `setConfig(subject, config)`. The interface SHALL be the contract every store implementation satisfies; consumers programming against it SHALL be able to swap stores without registry-side changes.
#### Scenario: every method is present and async
- **WHEN** any concrete store implementation is type-checked
- **THEN** all eight methods exist on the prototype and each returns a `Promise`
#### Scenario: deleteSubject returns false when the subject is absent
- **WHEN** `deleteSubject("never-registered")` is called on a store with no such subject
- **THEN** the returned Promise resolves to `false` (not `true`, not `undefined`)
#### Scenario: deleteSubject returns true when the subject existed and was removed
- **WHEN** a subject has been registered and `deleteSubject(subject)` is called
- **THEN** the returned Promise resolves to `true` AND a subsequent `hasSubject(subject)` resolves to `false`
**Verified by:** `packages/core/schema-registry/src/store/memory/memory.spec-conformance.test.ts::"every SchemaStoreAdapter method is present and async"` AND `packages/core/schema-registry/src/store/memory/memory.test.ts::"deleteSubject returns false when subject absent"` AND `packages/core/schema-registry/src/store/memory/memory.test.ts::"deleteSubject returns true when subject existed and was removed"`
---
### Requirement: MemorySchemaStore in-memory implementation
The package SHALL expose `MemorySchemaStore` implementing `SchemaStoreAdapter` against an internal `Map<string, SchemaDefinition[]>` for versions and `Map<string, SubjectConfig>` for configs. All methods SHALL be implemented; `getVersions` SHALL return a defensive copy so caller mutation does not leak; `getLatest` SHALL return the highest-versioned entry or `undefined`.
#### Scenario: round-trip a single subject
- **WHEN** a version is `putVersion`ed, then read via `getLatest`, `getVersion`, and `getVersions`
- **THEN** all three return the equivalent `SchemaDefinition` (deep-equal)
#### Scenario: getVersions returns a defensive copy
- **WHEN** `getVersions(subject)` is called and the caller mutates the returned array
- **THEN** a subsequent `getVersions(subject)` returns the unmutated original
#### Scenario: getLatest returns undefined for unknown subject
- **WHEN** `getLatest("never-registered")` is called
- **THEN** the resolved value is `undefined`
#### Scenario: getSubjects lists every subject with at least one version
- **WHEN** subjects A, B, C have versions and subject D was deleted
- **THEN** `getSubjects()` resolves to a list containing exactly `["A", "B", "C"]` in some order
**Verified by:** `packages/core/schema-registry/src/store/memory/memory.test.ts::"round-trips a single subject"` AND `packages/core/schema-registry/src/store/memory/memory.test.ts::"getVersions returns a defensive copy"` AND `packages/core/schema-registry/src/store/memory/memory.test.ts::"getLatest returns undefined for unknown subject"` AND `packages/core/schema-registry/src/store/memory/memory.test.ts::"getSubjects lists every subject with at least one version"`
---
### Requirement: DatabaseSchemaStore over @suaveplan/db
The package SHALL expose a generic `DatabaseSchemaStore<TAdapter extends DatabaseAdapter>` class implementing `SchemaStoreAdapter` over the `DatabaseAdapter` interface from `@suaveplan/db`. Constructor signature: `(db: TAdapter, tableName?: string, configTableName?: string)` defaulting to `"schema_versions"` / `"schema_configs"`. Every method SHALL issue parameterised SQL via `db.query` / `db.execute`. When the underlying adapter rejects, the store SHALL wrap the failure in `SchemaRegistryError(SCHEMA_REGISTRY.STORE_FAILURE, ...)` preserving the original error in the cause chain.
#### Scenario: putVersion issues a parameterised INSERT
- **WHEN** `putVersion` is called with a `SchemaDefinition`
- **THEN** `db.execute` is called once with an `INSERT INTO schema_versions ...` statement and positional params `[subject, version, schemaJson, fingerprint, registeredAt, deprecated]`
#### Scenario: getLatest issues an ORDER BY DESC LIMIT 1 query
- **WHEN** `getLatest("user-created")` is called
- **THEN** `db.query` receives a `SELECT ... ORDER BY version DESC LIMIT 1` statement and the params `["user-created"]`
#### Scenario: deleteSubject removes versions AND configs
- **WHEN** `deleteSubject(subject)` is called for a subject that exists
- **THEN** `db.execute` is called twice: once on `schema_versions WHERE subject = ?` and once on `schema_configs WHERE subject = ?` AND the resolved value is `true`
#### Scenario: adapter rejection raises STORE_FAILURE
- **WHEN** `db.query` rejects with an arbitrary error
- **THEN** the call to the store method rejects with `SchemaRegistryError` whose `code === SCHEMA_REGISTRY.STORE_FAILURE` and whose `cause` is the original error
#### Scenario: configurable table names
- **WHEN** `new DatabaseSchemaStore(db, "schemas_v2", "config_v2")` is constructed and any method runs
- **THEN** the issued SQL references `schemas_v2` / `config_v2`, not the defaults
**Verified by:** `packages/core/schema-registry/src/store/database/database.test.ts::"putVersion issues a parameterised INSERT"` AND `packages/core/schema-registry/src/store/database/database.test.ts::"getLatest issues an ORDER BY DESC LIMIT 1 query"` AND `packages/core/schema-registry/src/store/database/database.test.ts::"deleteSubject removes versions and configs and returns true"` AND `packages/core/schema-registry/src/store/database/database.spec-conformance.test.ts::"adapter rejection raises STORE_FAILURE preserving the cause"` AND `packages/core/schema-registry/src/store/database/database.test.ts::"references configured table names instead of defaults"`
---
### Requirement: Canonicalisation utilities for deterministic schema fingerprints
The package SHALL expose `canonicalizeSchema(schema)` returning a stable JSON string with fields sorted alphabetically at every level of nesting; `computeFingerprint(schema)` returning an 8-character lower-hex djb2 hash of the canonical form; `djb2Hash(input)` exposed as a building block; `flattenFields(fields, prefix?)` returning a `Map<string, SchemaField>` keyed by dot-notation paths; `isOptionalOrHasDefault(field)` returning `true` when `field.required === false` OR `field.defaultValue !== undefined`. Canonicalisation SHALL be field-order independent — schemas that differ only in field declaration order SHALL produce identical canonical strings and identical fingerprints.
#### Scenario: canonicalizeSchema sorts fields at every level
- **WHEN** a schema has top-level fields `["b", "a"]` and `b` has nested fields `["d", "c"]`
- **THEN** the canonical string places fields in the order `[a, b]` and `b.fields` in the order `[c, d]`
#### Scenario: computeFingerprint is order-independent
- **WHEN** two `SchemaDefinition` values differ only in field declaration order
- **THEN** `computeFingerprint(a) === computeFingerprint(b)`
#### Scenario: djb2Hash matches a known vector
- **WHEN** `djb2Hash("hello")` is called
- **THEN** the result is the lower-hex 8-char djb2 hash of `"hello"` (deterministic, independent of host endianness)
#### Scenario: flattenFields produces dot-notation paths
- **WHEN** flattenFields is called against `[{ name: "user", fields: [{ name: "id" }, { name: "name" }] }]`
- **THEN** the resulting map has keys `"user"`, `"user.id"`, `"user.name"`
#### Scenario: isOptionalOrHasDefault returns true for either condition
- **WHEN** the field has `required: false` OR `defaultValue: <anything other than undefined>`
- **THEN** `isOptionalOrHasDefault(field) === true`
#### Scenario: SchemaRegistry de-duplicates re-registration via fingerprint
- **WHEN** a schema is registered, then the IDENTICAL schema is registered again on the same subject
- **THEN** the registry returns the existing `SchemaDefinition` (same `version`, same `id`) without assigning a new version number
**Verified by:** `packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"canonicalizeSchema sorts fields at every level"` AND `packages/core/schema-registry/src/utils/canonical/canonical.spec-conformance.test.ts::"computeFingerprint is field-order independent"` AND `packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"djb2Hash matches a known vector"` AND `packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"flattenFields produces dot-notation paths"` AND `packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"isOptionalOrHasDefault returns true when either condition holds"` AND `packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"register de-duplicates identical schema by fingerprint"`
---
### Requirement: SubjectConfig with compatibility, version cap, and metadata
The package SHALL expose `SubjectConfig` interface — `{ compatibility: CompatibilityLevel; maxVersions?: number; metadata?: Record<string, string> }`. Per-subject configuration SHALL be persisted via the `SchemaStoreAdapter` (`getConfig` / `setConfig`) and consumed by the registry's compatibility check at register-time. Setting an unrecognised `compatibility` value via `setCompatibility` SHALL throw `SchemaRegistryError(SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE, ...)` with the offending value in `context.mode`. `maxVersions` (when present) overrides the registry-wide `maxVersionsPerSubject` for that subject.
#### Scenario: setCompatibility persists through the store
- **WHEN** a registry is constructed with a `MemorySchemaStore` and `setCompatibilityAsync(subject, "FULL_TRANSITIVE")` is called
- **THEN** `store.getConfig(subject)` resolves to a `SubjectConfig` with `compatibility === "FULL_TRANSITIVE"`
#### Scenario: per-subject maxVersions overrides the default
- **WHEN** `setConfig(subject, { compatibility: "BACKWARD", maxVersions: 5 })` is set and 6 versions are registered
- **THEN** `getAll(subject)` returns the most recent 5 (oldest is pruned)
#### Scenario: metadata round-trips
- **WHEN** `setConfig(subject, { compatibility: "BACKWARD", metadata: { owner: "platform" } })` is set
- **THEN** `getConfig(subject)` returns a config whose `metadata.owner === "platform"`
#### Scenario: unknown compatibility mode throws UNKNOWN_COMPATIBILITY_MODE
- **WHEN** `setCompatibility(subject, "MAGIC" as never)` is called
- **THEN** the call throws `SchemaRegistryError` with `code === SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE` and `context.mode === "MAGIC"`
**Verified by:** `packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"setCompatibility persists through the store"` AND `packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"per-subject maxVersions overrides the default"` AND `packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"SubjectConfig metadata round-trips through getConfig"` AND `packages/core/schema-registry/src/core/registry/schema-registry.spec-conformance.test.ts::"setCompatibility throws UNKNOWN_COMPATIBILITY_MODE for unrecognised mode"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,150 @@
# Tasks — Schema-registry completion
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read legacy `packages/universal/schema-registry/src` end-to-end at `../suaveplan/`
- [ ] 1.2 Read genesis `packages/core/schema-registry/src` end-to-end and `openspec/specs/` (no canonical schema-registry spec exists yet — this proposal seeds it)
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13 (spec-conformance audit), 14 (source mapping for migrations); §14a (proposal must list affected dependents); §0a (parity checklist)
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.5 Fill `## Source mapping` in `proposal.md` — every requirement cites a line range OR appears in the "no source line" table with justification
- [ ] 1.6 Draft `specs/schema-registry/spec.md` from `openspec/templates/spec-delta.md` — seven ADDED requirements (transitive, diff, store interface, memory store, database store, canonical utilities, subject config)
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
- [ ] 1.8 Draft `## Migration` section listing the `CompatibilityLevel` union widening and the new async constructor option
- [ ] 1.9 `bunx openspec change validate schema-registry-completion` exits with no errors
## 2. Implementation — types
- [ ] 2.1 Extend `CompatibilityLevel` in `src/types/schema-registry.types.ts` to include `BACKWARD_TRANSITIVE`, `FORWARD_TRANSITIVE`, `FULL_TRANSITIVE`
- [ ] 2.2 Add `SchemaField` interface in `src/types/schema-registry.types.ts` (legacy L15-L26); used by diff and canonical utilities
- [ ] 2.3 Add `DiffChangeType` union (`"added" | "removed" | "modified"`)
- [ ] 2.4 Add `SchemaDiffChange` and `SchemaDiffResult` interfaces
- [ ] 2.5 Add `TransitiveReport` and `TransitiveVersionResult` interfaces
- [ ] 2.6 Add `SubjectConfig` interface (`compatibility`, `maxVersions?`, `metadata?`)
- [ ] 2.7 Add `SchemaStoreAdapter` interface (8 async methods per legacy L82-L105)
- [ ] 2.8 Add optional `fingerprint?: string` to `SchemaDefinition`
## 3. Implementation — utilities
- [ ] 3.1 Create `src/utils/canonical/canonical.ts` exporting `canonicalizeSchema`, `computeFingerprint`, `djb2Hash`, `flattenFields`, `isOptionalOrHasDefault` (legacy `src/utils/utils.ts` L1-L70 verbatim)
- [ ] 3.2 Sibling `canonical.md` ≥200 words with basic + advanced examples
## 4. Implementation — transitive compatibility
- [ ] 4.1 Create `src/core/transitive/transitive.ts` exporting `checkTransitiveCompatibility(history, candidate, mode)`, `buildTransitiveReport(history, candidate, mode)`, `isTransitiveMode(mode)`, `stripTransitive(mode)`
- [ ] 4.2 Use the genesis `checkCompatibility` directly (do not duplicate the rules); transitive entry points iterate history and aggregate errors with `[v<n>]` prefixes (legacy convention, L31)
- [ ] 4.3 Sibling `transitive.md` ≥200 words
## 5. Implementation — diff
- [ ] 5.1 Create `src/core/diff/diff.ts` exporting `diffSchemas(oldSchema, newSchema)` and `formatDiffReport(result)` (legacy `src/diff/schema-differ.ts` L1-L175 verbatim)
- [ ] 5.2 Use `flattenFields` from canonical utilities; never duplicate the field-walking logic
- [ ] 5.3 Sibling `diff.md` ≥200 words
## 6. Implementation — stores
- [ ] 6.1 Create `src/store/memory/memory.ts` exporting `MemorySchemaStore` implementing `SchemaStoreAdapter` against an in-memory `Map<string, SchemaDefinition[]>` for versions and `Map<string, SubjectConfig>` for configs
- [ ] 6.2 Create `src/store/database/database.ts` exporting `DatabaseSchemaStore<TAdapter extends DatabaseAdapter>` (legacy `src/store/database-store.ts` L1-L147)
- [ ] 6.3 Add `@suaveplan/db` to `packages/core/schema-registry/package.json` `dependencies` (`workspace:^`); verify `STATUS.md` lists `@suaveplan/db` as completed (it is — `db-sqlite` is shipped)
- [ ] 6.4 Re-target the `DatabaseAdapter` import in the database store to `@suaveplan/db` (legacy used an inline interface)
- [ ] 6.5 Sibling `memory.md` and `database.md` ≥200 words each
## 7. Implementation — registry async surface
- [ ] 7.1 Extend `SchemaRegistry` constructor to accept `store?: SchemaStoreAdapter` in `RegistryOptions`
- [ ] 7.2 When `store` is supplied, route persistence through the store; gate sync method calls with `SchemaRegistryError(SCHEMA_REGISTRY.STORE_FAILURE, ..., { context: { reason: "sync_call_on_async_store" } })`
- [ ] 7.3 Add async siblings: `registerAsync`, `getLatestAsync`, `getVersionAsync`, `getAllAsync`, `listSubjectsAsync`, `deleteSubjectAsync`, `checkCompatibilityAsync`
- [ ] 7.4 In `register` / `registerAsync`, compute `fingerprint = computeFingerprint(schema as SchemaDefinition)` when `format === "json-schema"` and the schema matches `SchemaDefinition` shape; stamp it onto the resulting `SchemaDefinition`
- [ ] 7.5 De-dup: if the latest version's fingerprint equals the candidate's fingerprint, return the existing version (no new version assigned)
- [ ] 7.6 Wire the new `*_TRANSITIVE` modes into the existing `register` flow: when `state.compatibility` ends with `_TRANSITIVE`, call `checkTransitiveCompatibility(state.versions, schema, state.compatibility)` instead of `checkCompatibility(schema, latest, ...)`
- [ ] 7.7 Validate `setCompatibility(subject, level)` against the seven-mode union; throw `SchemaRegistryError(SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE, ...)` on invalid input
## 8. Errors
- [ ] 8.1 Add `STORE_FAILURE` and `UNKNOWN_COMPATIBILITY_MODE` to `SCHEMA_REGISTRY_CODES` in `src/errors/schema-registry-error.ts`
- [ ] 8.2 Update sibling `schema-registry-error.md` to document the two new codes
## 9. Wiring
- [ ] 9.1 Update `packages/core/schema-registry/src/index.ts` to re-export every new symbol (transitive helpers, diff, stores, canonical utilities, types)
- [ ] 9.2 Update `packages/core/schema-registry/package.json` `exports` map: add `./transitive`, `./diff`, `./store/memory`, `./store/database`, `./utils/canonical` subpath entries
- [ ] 9.3 Update root `README.md` `## Modules` section to link the new co-located `.md` files
- [ ] 9.4 Update the package description in `package.json` to mention transitive compatibility, diff, and pluggable stores
## 10. Tests
- [ ] 10.1 `src/core/transitive/transitive.test.ts` — multi-version history; `BACKWARD_TRANSITIVE` rejects when ANY old version disagrees; `FORWARD_TRANSITIVE` and `FULL_TRANSITIVE` analogous; per-version error prefixes `[v1]`, `[v2]`, ...
- [ ] 10.2 `src/core/transitive/transitive.spec-conformance.test.ts` — exhaustive `isTransitiveMode` / `stripTransitive` over all seven `CompatibilityLevel` values
- [ ] 10.3 `src/core/diff/diff.test.ts` — added / removed / modified / type-changed; nested-field diff; `formatDiffReport` rendering with `[BREAKING]` annotation
- [ ] 10.4 `src/core/diff/diff.spec-conformance.test.ts` — `fast-check` property `diffSchemas(a, a).changes === []`; `formatDiffReport` empty result text
- [ ] 10.5 `src/store/memory/memory.test.ts` — round-trip every method of `SchemaStoreAdapter`; `deleteSubject` returns `false` when subject absent
- [ ] 10.6 `src/store/memory/memory.spec-conformance.test.ts` — pins the contract (every method present, every method async)
- [ ] 10.7 `src/store/database/database.test.ts` — against a fake `DatabaseAdapter` that records SQL; verifies `INSERT` / `SELECT` / `DELETE` issued correctly with positional params
- [ ] 10.8 `src/store/database/database.spec-conformance.test.ts` — error path: `db.query` rejects → `STORE_FAILURE` raised with cause-chain preserving the original error
- [ ] 10.9 `src/utils/canonical/canonical.test.ts` — `canonicalizeSchema` sorts at every level; `computeFingerprint` deterministic; `djb2Hash` matches known vectors; `flattenFields` produces dot-paths; `isOptionalOrHasDefault` truthy/falsy
- [ ] 10.10 `src/utils/canonical/canonical.spec-conformance.test.ts` — `fast-check` property `canonicalizeSchema(a) === canonicalizeSchema(shuffleFields(a))` (canonicalisation is field-order independent)
- [ ] 10.11 Extend `src/core/registry/schema-registry.test.ts` — async surface end-to-end with `MemorySchemaStore`; verify `register` de-dup based on fingerprint; verify transitive mode triggers transitive checker
- [ ] 10.12 Extend `src/core/registry/schema-registry.spec-conformance.test.ts` — `UNKNOWN_COMPATIBILITY_MODE` thrown for invalid `setCompatibility`; `STORE_FAILURE` thrown when sync method called on store-backed registry
- [ ] 10.13 Every `it(...)` in 10.1-10.12 carries `{ timeout: 5000 }`
## 11. Documentation
- [ ] 11.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 11.2 README links every co-located `.md` under `## Modules`
- [ ] 11.3 README `## Features` enumerates the seven new capabilities (transitive, diff, store interface, memory store, database store, canonical utilities, subject config)
- [ ] 11.4 Update `CLAUDE.md` package listing for `@suaveplan/schema-registry` to include the new exports
## 12. Pre-archive verification (the 5-gate triple)
- [ ] 12.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
- [ ] 12.2 `bunx turbo lint typecheck test --filter=@suaveplan/schema-registry` — green
- [ ] 12.3 `bunx turbo test:coverage --filter=@suaveplan/schema-registry` — 100/100/100/100
- [ ] 12.4 `bun scripts/openspec-conformance.ts @suaveplan/schema-registry` — exit 0, no `error` findings
- [ ] 12.5 `bunx turbo build --filter=@suaveplan/schema-registry` — green
## 13. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 13.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (`CompatibilityLevel` widened, `SCHEMA_REGISTRY_CODES` keys, `DiffChangeType` literals)
- [ ] 13.2 Zod schemas wired at every declared boundary (`SchemaDefinition`, `SubjectConfig`, `SchemaField`, `RegistryOptions` extended)
- [ ] 13.3 Every error code in `SCHEMA_REGISTRY_CODES` thrown by at least one production path (`STORE_FAILURE` and `UNKNOWN_COMPATIBILITY_MODE` covered above; existing four codes remain in place)
- [ ] 13.4 Strict-mode behaviors tested on and off (transitive vs. non-transitive, store-backed vs. in-memory)
- [ ] 13.5 OTel instruments emitted from production paths and asserted with `InMemorySpanExporter` from `@suaveplan/opentelemetry` (the `register.total` counter and `check.duration_ms` histogram are already in place; new transitive/store paths should record to the same instruments)
- [ ] 13.6 Lifecycle dedup tested where claimed (re-register identical schema → fingerprint match → same `SchemaDefinition` returned, no version increment)
- [ ] 13.7 No retroactive tick — every box above reflects work done in this change
## 14. Dependent sweep
- [ ] 14.1 `bunx turbo test --filter=...@suaveplan/schema-registry` — green; `@suaveplan/events-schema` continues to pass
- [ ] 14.2 Search dependents for exhaustive `switch` over `CompatibilityLevel`: `grep -rln "CompatibilityLevel" packages/ src/`. For each hit, verify the switch handles the three new transitive arms (or falls through to the corresponding non-transitive arm)
- [ ] 14.3 Search dependents for `SchemaRegistry` constructor: `grep -rln "new SchemaRegistry\|createRegistry" packages/ src/`. None should require code changes — the new option is additive
## 15. Archive
- [ ] 15.1 Fold `specs/schema-registry/spec.md` into `openspec/specs/schema-registry/spec.md` (this seeds the canonical capability spec, since one does not yet exist)
- [ ] 15.2 Move `openspec/changes/schema-registry-completion/` → `openspec/archive/<yyyy-mm-dd>-schema-registry-completion/`
- [ ] 15.3 Refresh `MANIFEST.md` (this change adds `@suaveplan/db` as a dep)
## Audit Note (2026-05-02)
Audit verdict: **DEFERRED — 0/78 tasks evidenced in code**.
Evidence reviewed:
- `packages/core/schema-registry/src/` has only `core/{registry,compatibility}`, `errors/`, `types/`, `constants/`. No `core/transitive/`, no `core/diff/`, no `store/`, no `utils/canonical/`.
- `types/schema-registry.types.ts` `CompatibilityLevel` does not include `*_TRANSITIVE` arms (verified by absence of any matching directory or test).
- No `@suaveplan/db` dependency in `package.json` (DatabaseSchemaStore depends on it).
Missing pieces:
1. Transitive compatibility (`core/transitive/`) and the seven-mode `CompatibilityLevel` widening.
2. Schema diff engine (`core/diff/`) + `formatDiffReport`.
3. Canonical schema utilities (`utils/canonical/`), `SchemaStoreAdapter` interface, `MemorySchemaStore`, `DatabaseSchemaStore<TAdapter>` (requires adding `@suaveplan/db` dep), the async surface on `SchemaRegistry`, and the two new error codes.
Decision: leave change open. New canonical spec file + dep graph change + async API surface require focused implementation work.