# 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]` 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: [], 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 `" added, removed, 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` for versions and `Map` 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` 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` 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: ` - **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 }`. 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"`