diff --git a/config/contracts/registry-baseline.approval.json b/config/contracts/registry-baseline.approval.json index bce4eff..a061482 100644 --- a/config/contracts/registry-baseline.approval.json +++ b/config/contracts/registry-baseline.approval.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "snapshotDigest": "53468eada148a7afc821b0885b8a53bf47978bfd17adf26b424054beedcce608", + "snapshotDigest": "96b95ef1d50cce36e9fca8a98776a9e2e9e3a5dca24a6288ad83bf29c94aebd8", "owner": "frontend-platform", - "reason": "Make the reference feature route access policy explicitly session-required", - "approvedAt": "2026-08-01T07:18:30.000Z" + "reason": "Baseline canonical invalidation graph and topic-version contracts after FE-REG-QUERY retirement", + "approvedAt": "2026-08-01T15:15:45.537Z" } diff --git a/config/contracts/registry-baseline.json b/config/contracts/registry-baseline.json index 6bce1cd..5f21fa6 100644 --- a/config/contracts/registry-baseline.json +++ b/config/contracts/registry-baseline.json @@ -5,7 +5,7 @@ "registryId": "FE-REG-ROUTE", "owner": "feature-frontend-routing-release-recovery-runtime", "source": "src/features/installed-feature-contracts.ts", - "rowCount": 9, + "rowCount": 10, "contract": { "requiredFields": [ "routeId", @@ -121,6 +121,19 @@ "searchSchema": null, "title": "인증 연동" }, + "EXAMPLES_PLATFORM": { + "access": "public", + "chunkId": "route-examples-platform", + "errorSurface": "route-boundary", + "loadingSurface": "example-page", + "navigationLabel": "플랫폼 구성", + "navigationOrder": 15, + "paramsSchema": null, + "path": "/examples/platform", + "routeId": "EXAMPLES_PLATFORM", + "searchSchema": null, + "title": "플랫폼 구성" + }, "EXAMPLES_STATES": { "access": "public", "chunkId": "route-examples-states", @@ -218,7 +231,7 @@ "registryId": "FE-REG-ROUTE-RUNTIME", "owner": "feature-frontend-routing-release-recovery-runtime", "source": "src/features/installed-feature-contracts.ts", - "rowCount": 9, + "rowCount": 10, "contract": { "requiredFields": [ "routeId", @@ -275,6 +288,12 @@ "routeId": "EXAMPLES_AUTH", "searchCodec": "none" }, + "EXAMPLES_PLATFORM": { + "moduleId": "platform-overview-page", + "paramsCodec": "none", + "routeId": "EXAMPLES_PLATFORM", + "searchCodec": "none" + }, "EXAMPLES_STATES": { "moduleId": "state-gallery-page", "paramsCodec": "none", @@ -408,41 +427,101 @@ "rows": { "CREATE_REFERENCE_RESOURCE": { "auth": "external-session", + "authProfileId": "REFERENCE_EXTERNAL_BEARER", + "contractVersion": 2, + "csrfProfileId": "NO_CSRF_BEARER", "idempotency": "keyed", + "idempotencyKeyPolicy": "REQUIRED", + "mapperId": "ReferenceResourceMapper", + "maxEncodedSearchBytes": 0, + "maxResponseBytes": 32768, "method": "POST", "operationId": "CREATE_REFERENCE_RESOURCE", "owner": "feature-frontend-reference-feature-vertical-slice", "path": "/api/reference-resources", + "pathParameterNames": [], + "pathSchema": "NoRequest", + "protocol": "REST", + "providerId": "PRIMARY_API", + "replayPolicy": "KEYED_COMMAND", "requestSchema": "CreateReferenceResourceCommand", "requestSource": "body", + "responseMediaTypes": [ + "application/json" + ], "responseSchema": "ReferenceResourcePayload", "retry": "runtime", + "semantics": "COMMAND", + "successStatuses": [ + 200, + 201 + ], "timeoutMs": null }, "GET_REFERENCE_RESOURCE": { "auth": "external-session", + "authProfileId": "REFERENCE_EXTERNAL_BEARER", + "contractVersion": 2, + "csrfProfileId": "NO_CSRF_BEARER", "idempotency": "safe", + "idempotencyKeyPolicy": "NONE", + "mapperId": "ReferenceResourceMapper", + "maxEncodedSearchBytes": 0, + "maxResponseBytes": 32768, "method": "GET", "operationId": "GET_REFERENCE_RESOURCE", - "owner": "feature-frontend-form-page-platform", + "owner": "feature-frontend-reference-feature-vertical-slice", "path": "/api/reference-resources/{resourceId}", + "pathParameterNames": [ + "resourceId" + ], + "pathSchema": "ReferenceResourceParams", + "protocol": "REST", + "providerId": "PRIMARY_API", + "replayPolicy": "SAFE", "requestSchema": "NoRequest", "requestSource": "none", + "responseMediaTypes": [ + "application/json" + ], "responseSchema": "ReferenceResourcePayload", "retry": "runtime", + "semantics": "QUERY", + "successStatuses": [ + 200 + ], "timeoutMs": null }, "LIST_REFERENCE_RESOURCES": { "auth": "external-session", + "authProfileId": "REFERENCE_EXTERNAL_BEARER", + "contractVersion": 2, + "csrfProfileId": "NO_CSRF_BEARER", "idempotency": "safe", + "idempotencyKeyPolicy": "NONE", + "mapperId": "ReferenceResourceListMapper", + "maxEncodedSearchBytes": 4096, + "maxResponseBytes": 262144, "method": "GET", "operationId": "LIST_REFERENCE_RESOURCES", "owner": "feature-frontend-reference-feature-vertical-slice", "path": "/api/reference-resources", + "pathParameterNames": [], + "pathSchema": "NoRequest", + "protocol": "REST", + "providerId": "PRIMARY_API", + "replayPolicy": "SAFE", "requestSchema": "ReferenceResourceListQuery", "requestSource": "search", + "responseMediaTypes": [ + "application/json" + ], "responseSchema": "ReferenceResourceListPayload", "retry": "runtime", + "semantics": "QUERY", + "successStatuses": [ + 200 + ], "timeoutMs": null } } @@ -491,9 +570,12 @@ "rows": { "CreateReferenceResourceCommand": { "boundary": "api-request", + "direction": "REQUEST", "owner": "feature-frontend-reference-feature-vertical-slice", "runtime": "zod", - "schemaId": "CreateReferenceResourceCommand" + "schemaId": "CreateReferenceResourceCommand", + "schemaVersion": 1, + "unknownFieldPolicy": "REJECT_UNKNOWN" }, "none": { "boundary": "route-params", @@ -503,9 +585,12 @@ }, "NoRequest": { "boundary": "api-request", + "direction": "REQUEST", "owner": "feature-frontend-reference-feature-vertical-slice", "runtime": "zod", - "schemaId": "NoRequest" + "schemaId": "NoRequest", + "schemaVersion": 1, + "unknownFieldPolicy": "REJECT_UNKNOWN" }, "NotFoundSplat": { "boundary": "route-params", @@ -515,27 +600,39 @@ }, "ReferenceResourceListPayload": { "boundary": "api-response", + "direction": "RESPONSE", "owner": "feature-frontend-reference-feature-vertical-slice", "runtime": "zod", - "schemaId": "ReferenceResourceListPayload" + "schemaId": "ReferenceResourceListPayload", + "schemaVersion": 1, + "unknownFieldPolicy": "STRIP_UNKNOWN" }, "ReferenceResourceListQuery": { "boundary": "route-search-api-request", + "direction": "REQUEST", "owner": "feature-frontend-reference-feature-vertical-slice", "runtime": "zod", - "schemaId": "ReferenceResourceListQuery" + "schemaId": "ReferenceResourceListQuery", + "schemaVersion": 1, + "unknownFieldPolicy": "REJECT_UNKNOWN" }, "ReferenceResourceParams": { "boundary": "route-params", + "direction": "REQUEST", "owner": "feature-frontend-reference-feature-vertical-slice", "runtime": "zod", - "schemaId": "ReferenceResourceParams" + "schemaId": "ReferenceResourceParams", + "schemaVersion": 1, + "unknownFieldPolicy": "REJECT_UNKNOWN" }, "ReferenceResourcePayload": { "boundary": "api-response", + "direction": "RESPONSE", "owner": "feature-frontend-reference-feature-vertical-slice", "runtime": "zod", - "schemaId": "ReferenceResourcePayload" + "schemaId": "ReferenceResourcePayload", + "schemaVersion": 1, + "unknownFieldPolicy": "STRIP_UNKNOWN" } } }, @@ -585,12 +682,6 @@ "phase": "runtime", "required": true }, - "API_CONTRACT_VERSION": { - "classification": "public", - "defaultValue": null, - "phase": "runtime", - "required": true - }, "APP_ENV": { "classification": "public", "defaultValue": null, @@ -603,6 +694,12 @@ "phase": "runtime", "required": true }, + "CAPABILITY_OVERRIDES": { + "classification": "public", + "defaultValue": null, + "phase": "runtime", + "required": false + }, "CONFIG_SCHEMA_VERSION": { "classification": "public", "defaultValue": null, @@ -795,7 +892,7 @@ "registryId": "FE-REG-ERROR", "owner": "feature-frontend-error-classification-boundary-contract", "source": "src/contracts/errors.ts", - "rowCount": 31, + "rowCount": 38, "contract": { "requiredFields": [ "kind", @@ -1041,6 +1138,24 @@ "telemetryEvent": "release.mismatch.detected", "userMessageKey": "error.deploy_mismatch" }, + "DUPLICATE_IN_FLIGHT": { + "action": "none", + "defaultRetryable": false, + "kind": "DUPLICATE_IN_FLIGHT", + "redaction": [ + "authorization", + "body", + "cause", + "headers", + "query", + "stack", + "storageValue", + "url" + ], + "severity": "info", + "telemetryEvent": "api.request.failed", + "userMessageKey": "error.duplicate_in_flight" + }, "ENVELOPE_MISMATCH": { "action": "contact-support", "defaultRetryable": false, @@ -1077,6 +1192,24 @@ "telemetryEvent": "api.request.failed", "userMessageKey": "error.forbidden" }, + "IDENTITY_INTERN_LIMIT_EXCEEDED": { + "action": "retry", + "defaultRetryable": false, + "kind": "IDENTITY_INTERN_LIMIT_EXCEEDED", + "redaction": [ + "authorization", + "body", + "cause", + "headers", + "query", + "stack", + "storageValue", + "url" + ], + "severity": "warning", + "telemetryEvent": "api.request.failed", + "userMessageKey": "error.identity_intern_limit_exceeded" + }, "MALFORMED_JSON": { "action": "contact-support", "defaultRetryable": false, @@ -1095,6 +1228,24 @@ "telemetryEvent": "api.request.failed", "userMessageKey": "error.malformed_json" }, + "MAPPING_CONTRACT_VIOLATION": { + "action": "contact-support", + "defaultRetryable": false, + "kind": "MAPPING_CONTRACT_VIOLATION", + "redaction": [ + "authorization", + "body", + "cause", + "headers", + "query", + "stack", + "storageValue", + "url" + ], + "severity": "error", + "telemetryEvent": "api.request.failed", + "userMessageKey": "error.mapping_contract_violation" + }, "NETWORK_UNREACHABLE": { "action": "retry", "defaultRetryable": true, @@ -1131,6 +1282,24 @@ "telemetryEvent": "api.request.failed", "userMessageKey": "error.not_found" }, + "PAGINATION_CONTRACT_VIOLATION": { + "action": "contact-support", + "defaultRetryable": false, + "kind": "PAGINATION_CONTRACT_VIOLATION", + "redaction": [ + "authorization", + "body", + "cause", + "headers", + "query", + "stack", + "storageValue", + "url" + ], + "severity": "error", + "telemetryEvent": "api.request.failed", + "userMessageKey": "error.pagination_contract_violation" + }, "QUERY_CACHE_FAILURE": { "action": "retry", "defaultRetryable": false, @@ -1257,6 +1426,42 @@ "telemetryEvent": "api.request.failed", "userMessageKey": "error.request_timeout" }, + "RESPONSE_BODY_LIMIT": { + "action": "contact-support", + "defaultRetryable": false, + "kind": "RESPONSE_BODY_LIMIT", + "redaction": [ + "authorization", + "body", + "cause", + "headers", + "query", + "stack", + "storageValue", + "url" + ], + "severity": "error", + "telemetryEvent": "api.request.failed", + "userMessageKey": "error.response_body_limit" + }, + "RESULT_LIMIT_EXCEEDED": { + "action": "contact-support", + "defaultRetryable": false, + "kind": "RESULT_LIMIT_EXCEEDED", + "redaction": [ + "authorization", + "body", + "cause", + "headers", + "query", + "stack", + "storageValue", + "url" + ], + "severity": "error", + "telemetryEvent": "api.request.failed", + "userMessageKey": "error.result_limit_exceeded" + }, "SCHEMA_MISMATCH": { "action": "contact-support", "defaultRetryable": false, @@ -1275,6 +1480,24 @@ "telemetryEvent": "api.request.failed", "userMessageKey": "error.schema_mismatch" }, + "SCOPE_GENERATION_CHANGED": { + "action": "none", + "defaultRetryable": false, + "kind": "SCOPE_GENERATION_CHANGED", + "redaction": [ + "authorization", + "body", + "cause", + "headers", + "query", + "stack", + "storageValue", + "url" + ], + "severity": "info", + "telemetryEvent": "api.request.failed", + "userMessageKey": "error.scope_generation_changed" + }, "SERVER_FAILURE": { "action": "retry", "defaultRetryable": true, @@ -1404,68 +1627,116 @@ } }, { - "registryId": "FE-REG-QUERY", + "registryId": "FE-REG-QUERY-INVALIDATION", "owner": "feature-frontend-server-state-caching-contract", "source": "src/features/installed-feature-contracts.ts", "rowCount": 1, "contract": { "requiredFields": [ - "namespace", - "serialization", - "identity", - "invalidation", - "invalidationTopic", - "crossContext", - "version", - "persistence" + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" ], "fieldTypes": { - "namespace": "array", - "serialization": "string", - "identity": "string", - "invalidation": "string", - "invalidationTopic": "string", - "crossContext": "string", - "version": "integer", - "persistence": "string" - }, - "uniqueFields": [ - "namespace", - "invalidationTopic" - ], - "allowedValues": { - "crossContext": [ - "invalidate-only" - ], - "persistence": [ - "disabled" - ] + "topicId": "string", + "namespace.namespaceId": "string", + "namespace.namespaceVersion": "integer" }, + "uniqueFields": [], + "allowedValues": {}, "references": [], "keyField": null, "breakingFields": [ - "namespace", - "serialization", - "identity", - "invalidationTopic", - "crossContext", - "version", - "persistence" + "topics", + "namespaces", + "edges" + ], + "rowsPath": "edges", + "rowKeyFields": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ], + "uniqueFieldSets": [ + [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + ], + "snapshotProjection": { + "singletonRowKey": "invalidation-graph", + "canonicalArrayKeyFields": { + "topics": [ + "$value" + ], + "namespaces": [ + "namespaceId", + "namespaceVersion" + ], + "edges": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + } + } + }, + "rows": { + "invalidation-graph": { + "edges": [ + { + "namespace": { + "namespaceId": "reference-resource", + "namespaceVersion": 1 + }, + "topicId": "qinv.01k10f7m3w9p6r2c8v5n4x" + } + ], + "namespaces": [ + { + "namespaceId": "reference-resource", + "namespaceVersion": 1 + } + ], + "topics": [ + "qinv.01k10f7m3w9p6r2c8v5n4x" + ] + } + } + }, + { + "registryId": "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION", + "owner": "feature-frontend-server-state-caching-contract", + "source": "src/features/installed-feature-contracts.ts", + "rowCount": 1, + "contract": { + "requiredFields": [ + "topicId", + "topicVersion" + ], + "fieldTypes": { + "topicId": "string", + "topicVersion": "integer" + }, + "uniqueFields": [ + "topicId" + ], + "allowedValues": {}, + "references": [], + "keyField": null, + "breakingFields": [ + "topicId", + "topicVersion" + ], + "rowKeyFields": [ + "topicId" ] }, "rows": { - "REFERENCE_RESOURCE": { - "crossContext": "invalidate-only", - "identity": "no-pii-token-or-raw-url", - "invalidation": "reference resource namespace after successful mutation", - "invalidationTopic": "qinv.01k10f7m3w9p6r2c8v5n4x", - "namespace": [ - "reference-resource", - 1 - ], - "persistence": "disabled", - "serialization": "canonical-object-order", - "version": 1 + "[\"qinv.01k10f7m3w9p6r2c8v5n4x\"]": { + "topicId": "qinv.01k10f7m3w9p6r2c8v5n4x", + "topicVersion": 1 } } }, @@ -1655,7 +1926,7 @@ "registryId": "FE-REG-RELEASE", "owner": "feature-frontend-release-cache-rollback-contract", "source": "src/contracts/release-tokens.ts", - "rowCount": 8, + "rowCount": 9, "contract": { "requiredFields": [ "token", @@ -1681,7 +1952,7 @@ }, "rows": { "apiContractVersion": { - "compatibilityRole": "schema compatibility", + "compatibilityRole": "legacy V1 scalar; superseded by contractSetDigest", "source": "frontend/backend agreement", "token": "apiContractVersion" }, @@ -1715,6 +1986,11 @@ "source": "runtime config schema", "token": "configSchemaVersion" }, + "contractSetDigest": { + "compatibilityRole": "release coherence for multi-package contracts", + "source": "compiled external contract package set", + "token": "contractSetDigest" + }, "releaseId": { "compatibilityRole": "rollback target", "source": "deploy system", diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index 34c3c3c..a19036d 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -407,17 +407,25 @@ "namespace.namespaceVersion" ] ], + "snapshotProjection": { + "singletonRowKey": "invalidation-graph", + "canonicalArrayKeyFields": { + "topics": ["$value"], + "namespaces": ["namespaceId", "namespaceVersion"], + "edges": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + } + }, "consumers": [ { "path": "src/bootstrap/runtime-adapters.ts", "token": "indexInvalidationRegistry(INVALIDATION_REGISTRY)" } ], - "breakingFields": [ - "topicId", - "namespace.namespaceId", - "namespace.namespaceVersion" - ] + "breakingFields": ["topics", "namespaces", "edges"] }, { "registryId": "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION", diff --git a/scripts/check-registries.ts b/scripts/check-registries.ts index f303063..ac610f4 100644 --- a/scripts/check-registries.ts +++ b/scripts/check-registries.ts @@ -25,6 +25,10 @@ type RegistryReference = Readonly<{ targetField: string; }>; type RegistryConsumer = Readonly<{ path: string; token: string }>; +type RegistrySnapshotProjection = Readonly<{ + singletonRowKey: string; + canonicalArrayKeyFields: Readonly>; +}>; type RegistrySpecification = Readonly<{ registryId: string; owner: string; @@ -38,6 +42,7 @@ type RegistrySpecification = Readonly<{ keyField?: string; uniqueFields?: readonly string[]; uniqueFieldSets?: readonly (readonly string[])[]; + snapshotProjection?: RegistrySnapshotProjection; allowedValues?: Readonly>; references?: readonly RegistryReference[]; breakingFields?: readonly string[]; @@ -215,6 +220,111 @@ function projectRegistryRows( return projected; } +function snapshotKeyField( + value: unknown, + fieldPath: string, +): FieldLookup { + return fieldPath === "$value" + ? Object.freeze({ found: true, value }) + : lookupField(value, fieldPath); +} + +function replaceProjectedField( + row: RegistryRow, + fieldPath: string, + value: unknown, +): boolean { + const segments = fieldPath.split("."); + const finalSegment = segments.pop(); + if (!finalSegment) return false; + let current = row; + for (const segment of segments) { + const next = current[segment]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + return false; + } + current = next as RegistryRow; + } + current[finalSegment] = value; + return true; +} + +function projectSnapshotRows( + specification: RegistrySpecification, + exportedValue: unknown, + validatedRows: RegistryRows, +): RegistryRows | null { + const projection = specification.snapshotProjection; + if (!projection) return validatedRows; + if ( + !exportedValue || + typeof exportedValue !== "object" || + Array.isArray(exportedValue) + ) { + failures.push( + `${specification.registryId} snapshot projection requires an object export`, + ); + return null; + } + + const canonicalRow = canonicalizeRegistryValue(exportedValue) as RegistryRow; + for (const [fieldPath, keyFields] of Object.entries( + projection.canonicalArrayKeyFields, + )) { + const selected = lookupField(exportedValue, fieldPath); + if (!selected.found || !Array.isArray(selected.value)) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath} must be an array`, + ); + return null; + } + const keyedItems: Array<{ identity: string; value: unknown }> = []; + const identities = new Set(); + for (const [index, item] of selected.value.entries()) { + const keyValues = keyFields.map((field) => snapshotKeyField(item, field)); + const missingIndex = keyValues.findIndex((field) => !field.found); + if (missingIndex >= 0) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath}[${index}] is missing key ${keyFields[missingIndex]}`, + ); + return null; + } + const identity = JSON.stringify( + keyValues.map((field) => canonicalizeRegistryValue(field.value)), + ); + if (identities.has(identity)) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath} duplicates key ${identity}`, + ); + return null; + } + identities.add(identity); + keyedItems.push({ + identity, + value: canonicalizeRegistryValue(item), + }); + } + keyedItems.sort((left, right) => + left.identity.localeCompare(right.identity), + ); + if ( + !replaceProjectedField( + canonicalRow, + fieldPath, + keyedItems.map((item) => item.value), + ) + ) { + failures.push( + `${specification.registryId} snapshot field ${fieldPath} cannot be projected`, + ); + return null; + } + } + return Object.freeze({ + [projection.singletonRowKey]: canonicalRow, + }); +} + async function filesBelow(directory: string): Promise { try { const entries = await readdir(directory, { withFileTypes: true }); @@ -367,14 +477,19 @@ for (const specification of governance.registries) { ...(specification.uniqueFieldSets ? { uniqueFieldSets: specification.uniqueFieldSets } : {}), + ...(specification.snapshotProjection + ? { snapshotProjection: specification.snapshotProjection } + : {}), }); + const snapshotRows = projectSnapshotRows(specification, rows, registryRows); + if (!snapshotRows) continue; snapshots.push({ registryId: specification.registryId, owner: specification.owner, source: sourcePath ?? specification.path, - rowCount: Object.keys(registryRows).length, + rowCount: Object.keys(snapshotRows).length, contract, - rows: canonicalizeRegistryValue(registryRows), + rows: canonicalizeRegistryValue(snapshotRows), }); } diff --git a/tests/fixtures/registry/invalidation/governance.json b/tests/fixtures/registry/invalidation/governance.json index ed02939..77d4655 100644 --- a/tests/fixtures/registry/invalidation/governance.json +++ b/tests/fixtures/registry/invalidation/governance.json @@ -30,11 +30,19 @@ "namespace.namespaceVersion" ] ], - "breakingFields": [ - "topicId", - "namespace.namespaceId", - "namespace.namespaceVersion" - ] + "snapshotProjection": { + "singletonRowKey": "invalidation-graph", + "canonicalArrayKeyFields": { + "topics": ["$value"], + "namespaces": ["namespaceId", "namespaceVersion"], + "edges": [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion" + ] + } + }, + "breakingFields": ["topics", "namespaces", "edges"] }, { "registryId": "FIXTURE-INVALIDATION-VERSIONS", diff --git a/tests/unit/registry-governance.test.ts b/tests/unit/registry-governance.test.ts index 0f4974d..290421a 100644 --- a/tests/unit/registry-governance.test.ts +++ b/tests/unit/registry-governance.test.ts @@ -4,6 +4,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { + diffRegistrySnapshots, + validateBreakingEvidence, + verifyRegistryBaselineApproval, +} from "../../scripts/lib/registry-compatibility.ts"; + type RegistryDefinition = Readonly<{ registryId: string; owner: string; @@ -15,6 +21,10 @@ type RegistryDefinition = Readonly<{ rowKeyFields?: string[]; uniqueFields?: string[]; uniqueFieldSets?: string[][]; + snapshotProjection?: { + singletonRowKey: string; + canonicalArrayKeyFields: Record; + }; consumers?: Array<{ path: string; token: string }>; breakingFields?: string[]; }>; @@ -65,17 +75,25 @@ describe("registry governance manifest", () => { "namespace.namespaceVersion", ], ], + snapshotProjection: { + singletonRowKey: "invalidation-graph", + canonicalArrayKeyFields: { + topics: ["$value"], + namespaces: ["namespaceId", "namespaceVersion"], + edges: [ + "topicId", + "namespace.namespaceId", + "namespace.namespaceVersion", + ], + }, + }, consumers: [ { path: "src/bootstrap/runtime-adapters.ts", token: "indexInvalidationRegistry(INVALIDATION_REGISTRY)", }, ], - breakingFields: [ - "topicId", - "namespace.namespaceId", - "namespace.namespaceVersion", - ], + breakingFields: ["topics", "namespaces", "edges"], }); expect(byId.get("FE-REG-QUERY-INVALIDATION-TOPIC-VERSION")).toMatchObject({ path: "src/features/installed-feature-contracts.ts", @@ -113,18 +131,37 @@ describe("registry governance manifest", () => { expect(result.status, result.stderr).toBe(0); const report = JSON.parse(await readFile(artifact, "utf8")) as { - registries: Array<{ registryId: string; rows: Record }>; + registries: Array<{ + registryId: string; + rows: Record>; + }>; }; expect( report.registries.find( (registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES", )?.rows, - ).toHaveProperty('["qinv.fixture.changed","orders",1]'); - expect( - report.registries.find( - (registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES", - )?.rows, - ).toHaveProperty('["orders","qinv.fixture.changed",1]'); + ).toEqual({ + "invalidation-graph": { + topics: ["orders", "qinv.fixture.changed"], + namespaces: [ + { namespaceId: "orders", namespaceVersion: 1 }, + { namespaceId: "qinv.fixture.changed", namespaceVersion: 1 }, + ], + edges: [ + { + topicId: "orders", + namespace: { + namespaceId: "qinv.fixture.changed", + namespaceVersion: 1, + }, + }, + { + topicId: "qinv.fixture.changed", + namespace: { namespaceId: "orders", namespaceVersion: 1 }, + }, + ], + }, + }); expect( report.registries.find( (registry) => registry.registryId === "FIXTURE-INVALIDATION-VERSIONS", @@ -135,6 +172,175 @@ describe("registry governance manifest", () => { } }); + it("requires explicit evidence for post-baseline graph and version drift", async () => { + const directory = await mkdtemp( + path.join(tmpdir(), "registry-governance-tamper-"), + ); + const artifact = path.join(directory, "registries.json"); + try { + const result = spawnSync( + process.execPath, + [ + "scripts/check-registries.ts", + "--governance", + "tests/fixtures/registry/invalidation/governance.json", + "--artifact", + artifact, + "--no-baseline", + ], + { encoding: "utf8" }, + ); + expect(result.status, result.stderr).toBe(0); + + const report = JSON.parse(await readFile(artifact, "utf8")) as { + registries: Array<{ + registryId: string; + contract: Record; + rows: Record>; + }>; + }; + const baseline = { schemaVersion: 2, registries: report.registries }; + expect( + baseline.registries.find( + (registry) => registry.registryId === "FIXTURE-INVALIDATION-EDGES", + )?.rows["invalidation-graph"], + ).toBeDefined(); + const tamperCases = [ + { + name: "topic", + registryId: "FIXTURE-INVALIDATION-EDGES", + field: "topics", + apply(snapshot: typeof baseline) { + const graph = snapshot.registries.find( + (registry) => registry.registryId === this.registryId, + )?.rows["invalidation-graph"]; + (graph?.topics as string[])[0] = "tampered-topic"; + }, + }, + { + name: "namespace", + registryId: "FIXTURE-INVALIDATION-EDGES", + field: "namespaces", + apply(snapshot: typeof baseline) { + const graph = snapshot.registries.find( + (registry) => registry.registryId === this.registryId, + )?.rows["invalidation-graph"]; + const namespaces = graph?.namespaces as Array<{ + namespaceId: string; + }>; + namespaces[0]!.namespaceId = "tampered-namespace"; + }, + }, + { + name: "edge", + registryId: "FIXTURE-INVALIDATION-EDGES", + field: "edges", + apply(snapshot: typeof baseline) { + const graph = snapshot.registries.find( + (registry) => registry.registryId === this.registryId, + )?.rows["invalidation-graph"]; + const edges = graph?.edges as Array<{ topicId: string }>; + edges[0]!.topicId = "tampered-edge"; + }, + }, + { + name: "version", + registryId: "FIXTURE-INVALIDATION-VERSIONS", + field: "topicVersion", + apply(snapshot: typeof baseline) { + const versions = snapshot.registries.find( + (registry) => registry.registryId === this.registryId, + )?.rows; + const firstVersion = versions?.[Object.keys(versions)[0] ?? ""]; + if (!firstVersion) throw new Error("version fixture row missing"); + firstVersion.topicVersion = 2; + }, + }, + ]; + + for (const tamperCase of tamperCases) { + const current = structuredClone(baseline); + tamperCase.apply(current); + const diff = diffRegistrySnapshots(baseline, current); + expect(diff.impact, tamperCase.name).toBe("breaking"); + expect( + diff.changes.some((change) => change.kind === "registry-added"), + tamperCase.name, + ).toBe(false); + const breakingChanges = diff.changes.filter( + (change) => change.impact === "breaking", + ); + expect(breakingChanges, tamperCase.name).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + registryId: tamperCase.registryId, + field: tamperCase.field, + }), + ]), + ); + expect( + validateBreakingEvidence(diff, { changes: [] }).passed, + tamperCase.name, + ).toBe(false); + expect( + validateBreakingEvidence(diff, { + changes: breakingChanges.map((change) => ({ + changeId: change.changeId, + versionBump: "2", + migration: `migrate ${tamperCase.name}`, + compatibilityWindow: "one release", + rollback: `restore ${tamperCase.name} baseline`, + owner: "fixture-owner", + })), + }).passed, + tamperCase.name, + ).toBe(true); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("pins the full invalidation graph and versions in the approved repository baseline", async () => { + const baseline = JSON.parse( + await readFile("config/contracts/registry-baseline.json", "utf8"), + ) as { + schemaVersion: number; + registries: Array<{ + registryId: string; + rows: Record>; + }>; + }; + const approval = JSON.parse( + await readFile( + "config/contracts/registry-baseline.approval.json", + "utf8", + ), + ) as Record; + + expect(verifyRegistryBaselineApproval(baseline, approval).passed).toBe(true); + expect(baseline.registries).toHaveLength(11); + const graph = baseline.registries.find( + (registry) => registry.registryId === "FE-REG-QUERY-INVALIDATION", + ); + const versions = baseline.registries.find( + (registry) => + registry.registryId === + "FE-REG-QUERY-INVALIDATION-TOPIC-VERSION", + ); + expect(graph?.rows["invalidation-graph"]).toMatchObject({ + topics: expect.any(Array), + namespaces: expect.any(Array), + edges: expect.any(Array), + }); + expect(versions?.rows).toBeDefined(); + expect(approval).toMatchObject({ + owner: "frontend-platform", + reason: + "Baseline canonical invalidation graph and topic-version contracts after FE-REG-QUERY retirement", + }); + }); + it("rejects duplicate composite invalidation edges after projection", async () => { const directory = await mkdtemp( path.join(tmpdir(), "registry-governance-duplicate-"),