From 00edf2be78bb3a2e7dcc34fe3ef25562f092de00 Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:26:47 +0800 Subject: [PATCH 01/26] database: derive source-backed catalog identity drift --- .../0008_catalog_identity_drift.sql | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 packages/database/migrations/0008_catalog_identity_drift.sql diff --git a/packages/database/migrations/0008_catalog_identity_drift.sql b/packages/database/migrations/0008_catalog_identity_drift.sql new file mode 100644 index 0000000..4d4231d --- /dev/null +++ b/packages/database/migrations/0008_catalog_identity_drift.sql @@ -0,0 +1,192 @@ +BEGIN; + +SET search_path TO modelapse, public; + +CREATE TRIGGER alias_resolution_events_append_only +BEFORE UPDATE OR DELETE ON alias_resolution_events +FOR EACH ROW EXECUTE FUNCTION prevent_append_only_mutation(); + +CREATE OR REPLACE FUNCTION protect_model_execution_binding_history() +RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'model execution bindings cannot be deleted'; + END IF; + + IF NEW.id IS DISTINCT FROM OLD.id + OR NEW.model_id IS DISTINCT FROM OLD.model_id + OR NEW.endpoint_id IS DISTINCT FROM OLD.endpoint_id + OR NEW.api_model_id IS DISTINCT FROM OLD.api_model_id + OR NEW.snapshot_id IS DISTINCT FROM OLD.snapshot_id + OR NEW.valid_from IS DISTINCT FROM OLD.valid_from + OR NEW.source_id IS DISTINCT FROM OLD.source_id + OR NEW.metadata IS DISTINCT FROM OLD.metadata + OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN + RAISE EXCEPTION 'model execution binding identity is immutable'; + END IF; + + IF OLD.valid_to IS NOT NULL + AND NEW.valid_to IS DISTINCT FROM OLD.valid_to THEN + RAISE EXCEPTION 'closed model execution bindings cannot be rewritten'; + END IF; + + IF OLD.valid_to IS NULL + AND NEW.valid_to IS NULL THEN + RETURN NEW; + END IF; + + IF OLD.valid_to IS NULL + AND NEW.valid_to IS NOT NULL + AND NEW.valid_to > OLD.valid_from THEN + RETURN NEW; + END IF; + + RAISE EXCEPTION 'model execution binding may only be closed once'; +END; +$$; + +CREATE TRIGGER model_execution_bindings_history_guard +BEFORE UPDATE OR DELETE ON model_execution_bindings +FOR EACH ROW EXECUTE FUNCTION protect_model_execution_binding_history(); + +CREATE VIEW catalog_identity_drift_events AS +WITH alias_history AS ( + SELECT + are.id AS current_record_id, + are.alias_id, + ma.provider_id, + ma.alias, + are.observed_at AS occurred_at, + COALESCE(are.resolved_model_id, ms.model_id) AS current_model_id, + are.resolved_snapshot_id AS current_snapshot_id, + are.source_id AS current_source_id, + lag(are.id) OVER alias_order AS previous_record_id, + lag(COALESCE(are.resolved_model_id, ms.model_id)) OVER alias_order + AS previous_model_id, + lag(are.resolved_snapshot_id) OVER alias_order AS previous_snapshot_id, + lag(are.source_id) OVER alias_order AS previous_source_id + FROM alias_resolution_events are + JOIN model_aliases ma ON ma.id = are.alias_id + LEFT JOIN model_snapshots ms ON ms.id = are.resolved_snapshot_id + WINDOW alias_order AS ( + PARTITION BY are.alias_id + ORDER BY are.observed_at, are.id + ) +), +alias_changes AS ( + SELECT + 'alias:' || current_record_id::text AS event_id, + 'alias_target_changed'::text AS change_type, + occurred_at, + provider_id, + alias_id, + alias, + previous_model_id, + current_model_id, + previous_snapshot_id, + current_snapshot_id, + NULL::uuid AS previous_endpoint_id, + NULL::uuid AS current_endpoint_id, + NULL::text AS previous_api_model_id, + NULL::text AS current_api_model_id, + previous_record_id, + current_record_id, + previous_source_id, + current_source_id, + array_remove(ARRAY[ + CASE + WHEN previous_model_id IS DISTINCT FROM current_model_id + THEN 'model' + END, + CASE + WHEN previous_snapshot_id IS DISTINCT FROM current_snapshot_id + THEN 'snapshot' + END + ], NULL)::text[] AS changed_fields + FROM alias_history + WHERE previous_record_id IS NOT NULL + AND current_source_id IS NOT NULL + AND ( + previous_model_id IS DISTINCT FROM current_model_id + OR previous_snapshot_id IS DISTINCT FROM current_snapshot_id + ) +), +binding_history AS ( + SELECT + meb.id AS current_record_id, + m.provider_id, + meb.model_id AS current_model_id, + meb.snapshot_id AS current_snapshot_id, + meb.endpoint_id AS current_endpoint_id, + meb.api_model_id AS current_api_model_id, + meb.source_id AS current_source_id, + meb.valid_from AS occurred_at, + pe.path, + lag(meb.id) OVER binding_order AS previous_record_id, + lag(meb.model_id) OVER binding_order AS previous_model_id, + lag(meb.snapshot_id) OVER binding_order AS previous_snapshot_id, + lag(meb.endpoint_id) OVER binding_order AS previous_endpoint_id, + lag(meb.api_model_id) OVER binding_order AS previous_api_model_id, + lag(meb.source_id) OVER binding_order AS previous_source_id, + lag(meb.valid_to) OVER binding_order AS previous_valid_to + FROM model_execution_bindings meb + JOIN models m ON m.id = meb.model_id + JOIN provider_endpoints pe ON pe.id = meb.endpoint_id + WINDOW binding_order AS ( + PARTITION BY meb.model_id, pe.path + ORDER BY meb.valid_from, meb.created_at, meb.id + ) +), +binding_changes AS ( + SELECT + 'binding:' || current_record_id::text AS event_id, + 'execution_binding_changed'::text AS change_type, + occurred_at, + provider_id, + NULL::uuid AS alias_id, + NULL::text AS alias, + previous_model_id, + current_model_id, + previous_snapshot_id, + current_snapshot_id, + previous_endpoint_id, + current_endpoint_id, + previous_api_model_id, + current_api_model_id, + previous_record_id, + current_record_id, + previous_source_id, + current_source_id, + array_remove(ARRAY[ + CASE + WHEN previous_endpoint_id IS DISTINCT FROM current_endpoint_id + THEN 'endpoint' + END, + CASE + WHEN previous_api_model_id IS DISTINCT FROM current_api_model_id + THEN 'api_model' + END, + CASE + WHEN previous_snapshot_id IS DISTINCT FROM current_snapshot_id + THEN 'snapshot' + END + ], NULL)::text[] AS changed_fields + FROM binding_history + WHERE previous_record_id IS NOT NULL + AND previous_valid_to IS NOT NULL + AND previous_valid_to <= occurred_at + AND ( + previous_endpoint_id IS DISTINCT FROM current_endpoint_id + OR previous_api_model_id IS DISTINCT FROM current_api_model_id + OR previous_snapshot_id IS DISTINCT FROM current_snapshot_id + ) +) +SELECT * FROM alias_changes +UNION ALL +SELECT * FROM binding_changes; + +CREATE INDEX model_execution_bindings_model_validity_idx + ON model_execution_bindings + (model_id, valid_from ASC, created_at ASC); + +COMMIT; From 6b2db90d64a99750ba6da42a72577fd4ca63508b Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:27:37 +0800 Subject: [PATCH 02/26] catalog: add chronological first-party identity observer --- packages/catalog-admin/src/model-catalog.ts | 307 ++++++++++++++++++++ 1 file changed, 307 insertions(+) diff --git a/packages/catalog-admin/src/model-catalog.ts b/packages/catalog-admin/src/model-catalog.ts index 739668d..d94019a 100644 --- a/packages/catalog-admin/src/model-catalog.ts +++ b/packages/catalog-admin/src/model-catalog.ts @@ -9,6 +9,19 @@ export interface FirstPartyModelRegistration { readonly aliasId: string; } +export interface FirstPartyIdentityObservation { + readonly sourceId: string; + readonly providerId: string; + readonly endpointId: string; + readonly modelId: string; + readonly snapshotId: string | null; + readonly bindingId: string; + readonly bindingChanged: boolean; + readonly aliasId: string; + readonly aliasObservationId: string; + readonly observedAt: string; +} + async function ensureSource( client: PoolClient, input: { @@ -41,6 +54,43 @@ async function ensureSource( return id; } +async function recordSourceObservation( + client: PoolClient, + input: { + readonly sourceType: string; + readonly url: string; + readonly title: string; + readonly retrievedAt: string; + readonly contentSha256?: string; + }, +): Promise { + const inserted = await client.query<{ id: string }>( + `INSERT INTO modelapse.source_records + (source_type, url, title, retrieved_at, content_sha256) + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [ + input.sourceType, + input.url, + input.title, + input.retrievedAt, + input.contentSha256 ?? null, + ], + ); + const id = inserted.rows[0]?.id; + if (!id) throw new Error("Identity observation source insert did not return an id"); + return id; +} + +function normalizedObservationTime(value: string | undefined): string { + if (!value) return new Date().toISOString(); + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) { + throw new Error("observedAt must be an ISO-8601 timestamp"); + } + return parsed.toISOString(); +} + export class PgModelCatalogAdmin { constructor(private readonly pool: Pool) {} @@ -283,6 +333,263 @@ export class PgModelCatalogAdmin { } } + async observeFirstPartyIdentity(input: { + readonly providerSlug: string; + readonly canonicalSlug: string; + readonly apiModelId: string; + readonly providerSnapshotId?: string; + readonly sourceUrl: string; + readonly sourceTitle: string; + readonly contentSha256?: string; + readonly observedAt?: string; + }): Promise { + const providerSlug = input.providerSlug.trim(); + const canonicalSlug = input.canonicalSlug.trim(); + const apiModelId = input.apiModelId.trim(); + const providerSnapshotId = input.providerSnapshotId?.trim() || null; + const sourceUrl = input.sourceUrl.trim(); + const sourceTitle = input.sourceTitle.trim(); + const contentSha256 = input.contentSha256?.trim(); + const observedAt = normalizedObservationTime(input.observedAt); + + if (!providerSlug || !canonicalSlug || !apiModelId || !sourceUrl || !sourceTitle) { + throw new Error("Identity observation fields must be non-empty"); + } + if ( + contentSha256 && + !/^[0-9a-f]{64}$/.test(contentSha256) + ) { + throw new Error("contentSha256 must be a lowercase SHA-256 digest"); + } + + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + "SELECT pg_advisory_xact_lock(hashtext($1))", + ["modelapse:identity:" + providerSlug + ":" + canonicalSlug], + ); + + const resolved = await client.query<{ + provider_id: string; + model_id: string; + endpoint_id: string; + }>( + `SELECT + p.id AS provider_id, + m.id AS model_id, + pe.id AS endpoint_id + FROM modelapse.providers p + JOIN modelapse.models m + ON m.provider_id = p.id + AND m.canonical_slug = $2 + JOIN modelapse.provider_endpoints pe + ON pe.provider_id = p.id + AND pe.path = 'first_party_direct' + AND pe.source_id IS NOT NULL + AND (pe.valid_from IS NULL OR pe.valid_from <= $3::timestamptz) + AND (pe.valid_to IS NULL OR pe.valid_to > $3::timestamptz) + WHERE p.slug = $1 + ORDER BY pe.valid_from DESC NULLS LAST, pe.id + LIMIT 1`, + [providerSlug, canonicalSlug, observedAt], + ); + + const providerId = resolved.rows[0]?.provider_id; + const modelId = resolved.rows[0]?.model_id; + const endpointId = resolved.rows[0]?.endpoint_id; + if (!providerId || !modelId || !endpointId) { + throw new Error( + "Canonical model does not have a sourced first-party direct endpoint at observedAt", + ); + } + + const sourceId = await recordSourceObservation(client, { + sourceType: "provider_docs", + url: sourceUrl, + title: sourceTitle, + retrievedAt: observedAt, + ...(contentSha256 ? { contentSha256 } : {}), + }); + + let snapshotId: string | null = null; + if (providerSnapshotId) { + const existingSnapshot = await client.query<{ id: string }>( + `SELECT id + FROM modelapse.model_snapshots + WHERE model_id = $1 + AND provider_snapshot_id = $2 + LIMIT 1`, + [modelId, providerSnapshotId], + ); + snapshotId = existingSnapshot.rows[0]?.id ?? null; + + if (!snapshotId) { + const insertedSnapshot = await client.query<{ id: string }>( + `INSERT INTO modelapse.model_snapshots + (model_id, provider_snapshot_id, valid_from, source_id) + VALUES ($1, $2, $3, $4) + RETURNING id`, + [modelId, providerSnapshotId, observedAt, sourceId], + ); + snapshotId = insertedSnapshot.rows[0]?.id ?? null; + } + if (!snapshotId) { + throw new Error("Provider snapshot observation insert failed"); + } + } + + const currentBinding = await client.query<{ + id: string; + api_model_id: string; + snapshot_id: string | null; + valid_from: Date; + }>( + `SELECT id, api_model_id, snapshot_id, valid_from + FROM modelapse.model_execution_bindings + WHERE model_id = $1 + AND endpoint_id = $2 + AND valid_to IS NULL + ORDER BY valid_from DESC, created_at DESC + LIMIT 1 + FOR UPDATE`, + [modelId, endpointId], + ); + + const current = currentBinding.rows[0]; + let bindingId = current?.id; + let bindingChanged = false; + + if ( + current && + (current.api_model_id !== apiModelId || + current.snapshot_id !== snapshotId) + ) { + if (new Date(observedAt) <= current.valid_from) { + throw new Error( + "Identity observations must advance beyond the current binding validFrom", + ); + } + + await client.query( + `UPDATE modelapse.model_execution_bindings + SET valid_to = $2 + WHERE id = $1 + AND valid_to IS NULL`, + [current.id, observedAt], + ); + bindingId = undefined; + bindingChanged = true; + } + + if (!bindingId) { + const insertedBinding = await client.query<{ id: string }>( + `INSERT INTO modelapse.model_execution_bindings + ( + model_id, + endpoint_id, + api_model_id, + snapshot_id, + valid_from, + source_id + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [modelId, endpointId, apiModelId, snapshotId, observedAt, sourceId], + ); + bindingId = insertedBinding.rows[0]?.id; + bindingChanged = bindingChanged || Boolean(current); + } + if (!bindingId) throw new Error("Identity observation binding insert failed"); + + await client.query( + `INSERT INTO modelapse.model_aliases + (provider_id, alias) + VALUES ($1, $2) + ON CONFLICT (provider_id, alias) DO NOTHING`, + [providerId, apiModelId], + ); + + const alias = await client.query<{ id: string }>( + `SELECT id + FROM modelapse.model_aliases + WHERE provider_id = $1 + AND alias = $2`, + [providerId, apiModelId], + ); + const aliasId = alias.rows[0]?.id; + if (!aliasId) throw new Error("Identity observation alias could not be resolved"); + + const latestAliasObservation = await client.query<{ observed_at: Date }>( + `SELECT observed_at + FROM modelapse.alias_resolution_events + WHERE alias_id = $1 + ORDER BY observed_at DESC, id DESC + LIMIT 1`, + [aliasId], + ); + const latestObservedAt = latestAliasObservation.rows[0]?.observed_at; + if (latestObservedAt && new Date(observedAt) <= latestObservedAt) { + throw new Error( + "Identity observations for an alias must be strictly chronological", + ); + } + + const observation = await client.query<{ id: string }>( + `INSERT INTO modelapse.alias_resolution_events + ( + alias_id, + resolved_model_id, + resolved_snapshot_id, + observed_at, + source_type, + source_id, + confidence, + raw_observation + ) + VALUES ($1, $2, $3, $4, 'provider_docs', $5, 1.0, $6::jsonb) + RETURNING id`, + [ + aliasId, + modelId, + snapshotId, + observedAt, + sourceId, + JSON.stringify({ + apiModelId, + endpointId, + providerSnapshotId, + collector: "catalog-admin-identity-observer", + }), + ], + ); + const aliasObservationId = observation.rows[0]?.id; + if (!aliasObservationId) { + throw new Error("Alias identity observation insert failed"); + } + + await client.query("COMMIT"); + + return { + sourceId, + providerId, + endpointId, + modelId, + snapshotId, + bindingId, + bindingChanged, + aliasId, + aliasObservationId, + observedAt, + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async bootstrapDeepSeekFlash(): Promise { return this.registerFirstPartyModel({ providerSlug: "deepseek", From 147daa300a98d85422aa420df2c36c9e445fe1d5 Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:28:42 +0800 Subject: [PATCH 03/26] archive: project derived catalog identity drift --- .../persistence/src/archive-repository.ts | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/packages/persistence/src/archive-repository.ts b/packages/persistence/src/archive-repository.ts index 1d36988..27595f5 100644 --- a/packages/persistence/src/archive-repository.ts +++ b/packages/persistence/src/archive-repository.ts @@ -137,6 +137,47 @@ export interface ArchiveSourceView { readonly contentSha256: string | null; } +export interface ArchiveCatalogIdentityStateView { + readonly model: { + readonly id: string; + readonly canonicalSlug: string; + readonly marketingName: string; + } | null; + readonly snapshot: { + readonly id: string; + readonly providerSnapshotId: string; + } | null; + readonly endpoint: { + readonly id: string; + readonly path: string; + readonly baseUrl: string; + readonly hostname: string; + } | null; + readonly apiModelId: string | null; +} + +export interface ArchiveCatalogChangeView { + readonly id: string; + readonly changeType: "alias_target_changed" | "execution_binding_changed"; + readonly occurredAt: string; + readonly provider: { + readonly id: string; + readonly slug: string; + readonly name: string; + }; + readonly alias: { + readonly id: string; + readonly value: string; + } | null; + readonly changedFields: readonly string[]; + readonly previous: ArchiveCatalogIdentityStateView; + readonly current: ArchiveCatalogIdentityStateView; + readonly previousRecordId: string; + readonly currentRecordId: string; + readonly previousSource: ArchiveSourceView | null; + readonly currentSource: ArchiveSourceView; +} + export interface ArchiveModelAliasResolutionView { readonly id: string; readonly alias: { @@ -294,6 +335,7 @@ export interface ArchiveModelDetailView extends ArchiveModelView { readonly aliasResolutions: readonly ArchiveModelAliasResolutionView[]; readonly executionBindings: readonly ArchiveModelExecutionBindingView[]; readonly identityTimeline: readonly ArchiveIdentityTimelineEventView[]; + readonly identityDrift: readonly ArchiveCatalogChangeView[]; readonly testCoverage: readonly ArchiveTestCoverageView[]; readonly recentRuns: readonly ArchiveRunView[]; readonly timeline: readonly ArchiveTimelineEventView[]; @@ -643,6 +685,253 @@ export class PgArchiveRepository { })); } + async listCatalogChanges(input: { + readonly modelId?: string; + readonly providerSlug?: string; + readonly limit?: number; + } = {}): Promise { + const limit = input.limit ?? 50; + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new Error( + "Archive catalog-change limit must be an integer between 1 and 100", + ); + } + + const result = await this.pool.query<{ + event_id: string; + change_type: "alias_target_changed" | "execution_binding_changed"; + occurred_at: Date; + provider_id: string; + provider_slug: string; + provider_name: string; + alias_id: string | null; + alias_value: string | null; + changed_fields: string[]; + previous_record_id: string; + current_record_id: string; + previous_model_id: string | null; + previous_canonical_slug: string | null; + previous_marketing_name: string | null; + current_model_id: string | null; + current_canonical_slug: string | null; + current_marketing_name: string | null; + previous_snapshot_id: string | null; + previous_snapshot_value: string | null; + current_snapshot_id: string | null; + current_snapshot_value: string | null; + previous_endpoint_id: string | null; + previous_endpoint_path: string | null; + previous_endpoint_base_url: string | null; + previous_endpoint_hostname: string | null; + current_endpoint_id: string | null; + current_endpoint_path: string | null; + current_endpoint_base_url: string | null; + current_endpoint_hostname: string | null; + previous_api_model_id: string | null; + current_api_model_id: string | null; + previous_source_id: string | null; + previous_source_type: string | null; + previous_source_url: string | null; + previous_source_title: string | null; + previous_source_author: string | null; + previous_source_published_at: Date | null; + previous_source_retrieved_at: Date | null; + previous_source_content_sha256: string | null; + current_source_id: string; + current_source_type: string; + current_source_url: string | null; + current_source_title: string | null; + current_source_author: string | null; + current_source_published_at: Date | null; + current_source_retrieved_at: Date; + current_source_content_sha256: string | null; + }>( + `SELECT + drift.event_id, + drift.change_type, + drift.occurred_at, + p.id AS provider_id, + p.slug AS provider_slug, + p.name AS provider_name, + drift.alias_id, + drift.alias AS alias_value, + drift.changed_fields, + drift.previous_record_id, + drift.current_record_id, + previous_model.id AS previous_model_id, + previous_model.canonical_slug AS previous_canonical_slug, + previous_model.marketing_name AS previous_marketing_name, + current_model.id AS current_model_id, + current_model.canonical_slug AS current_canonical_slug, + current_model.marketing_name AS current_marketing_name, + previous_snapshot.id AS previous_snapshot_id, + previous_snapshot.provider_snapshot_id AS previous_snapshot_value, + current_snapshot.id AS current_snapshot_id, + current_snapshot.provider_snapshot_id AS current_snapshot_value, + previous_endpoint.id AS previous_endpoint_id, + previous_endpoint.path AS previous_endpoint_path, + previous_endpoint.base_url AS previous_endpoint_base_url, + previous_endpoint.hostname AS previous_endpoint_hostname, + current_endpoint.id AS current_endpoint_id, + current_endpoint.path AS current_endpoint_path, + current_endpoint.base_url AS current_endpoint_base_url, + current_endpoint.hostname AS current_endpoint_hostname, + drift.previous_api_model_id, + drift.current_api_model_id, + previous_source.id AS previous_source_id, + previous_source.source_type AS previous_source_type, + previous_source.url AS previous_source_url, + previous_source.title AS previous_source_title, + previous_source.author AS previous_source_author, + previous_source.published_at AS previous_source_published_at, + previous_source.retrieved_at AS previous_source_retrieved_at, + previous_source.content_sha256 AS previous_source_content_sha256, + current_source.id AS current_source_id, + current_source.source_type AS current_source_type, + current_source.url AS current_source_url, + current_source.title AS current_source_title, + current_source.author AS current_source_author, + current_source.published_at AS current_source_published_at, + current_source.retrieved_at AS current_source_retrieved_at, + current_source.content_sha256 AS current_source_content_sha256 + FROM modelapse.catalog_identity_drift_events drift + JOIN modelapse.providers p ON p.id = drift.provider_id + LEFT JOIN modelapse.models previous_model + ON previous_model.id = drift.previous_model_id + LEFT JOIN modelapse.models current_model + ON current_model.id = drift.current_model_id + LEFT JOIN modelapse.model_snapshots previous_snapshot + ON previous_snapshot.id = drift.previous_snapshot_id + LEFT JOIN modelapse.model_snapshots current_snapshot + ON current_snapshot.id = drift.current_snapshot_id + LEFT JOIN modelapse.provider_endpoints previous_endpoint + ON previous_endpoint.id = drift.previous_endpoint_id + LEFT JOIN modelapse.provider_endpoints current_endpoint + ON current_endpoint.id = drift.current_endpoint_id + LEFT JOIN modelapse.source_records previous_source + ON previous_source.id = drift.previous_source_id + JOIN modelapse.source_records current_source + ON current_source.id = drift.current_source_id + WHERE ( + $1::uuid IS NULL + OR drift.previous_model_id = $1 + OR drift.current_model_id = $1 + ) + AND ($2::text IS NULL OR p.slug = $2) + ORDER BY drift.occurred_at DESC, drift.event_id DESC + LIMIT $3`, + [input.modelId ?? null, input.providerSlug ?? null, limit], + ); + + const modelState = ( + id: string | null, + canonicalSlug: string | null, + marketingName: string | null, + ) => + id && canonicalSlug && marketingName + ? { id, canonicalSlug, marketingName } + : null; + + const snapshotState = ( + id: string | null, + providerSnapshotId: string | null, + ) => + id && providerSnapshotId + ? { id, providerSnapshotId } + : null; + + const endpointState = ( + id: string | null, + path: string | null, + baseUrl: string | null, + hostname: string | null, + ) => + id && path && baseUrl && hostname + ? { id, path, baseUrl, hostname } + : null; + + return result.rows.map((row) => { + const currentSource = archiveSourceView({ + source_id: row.current_source_id, + source_type: row.current_source_type, + source_url: row.current_source_url, + source_title: row.current_source_title, + source_author: row.current_source_author, + source_published_at: row.current_source_published_at, + source_retrieved_at: row.current_source_retrieved_at, + source_content_sha256: row.current_source_content_sha256, + }); + if (!currentSource) { + throw new Error("Catalog drift event is missing its current source"); + } + + return { + id: row.event_id, + changeType: row.change_type, + occurredAt: row.occurred_at.toISOString(), + provider: { + id: row.provider_id, + slug: row.provider_slug, + name: row.provider_name, + }, + alias: + row.alias_id && row.alias_value + ? { id: row.alias_id, value: row.alias_value } + : null, + changedFields: row.changed_fields, + previous: { + model: modelState( + row.previous_model_id, + row.previous_canonical_slug, + row.previous_marketing_name, + ), + snapshot: snapshotState( + row.previous_snapshot_id, + row.previous_snapshot_value, + ), + endpoint: endpointState( + row.previous_endpoint_id, + row.previous_endpoint_path, + row.previous_endpoint_base_url, + row.previous_endpoint_hostname, + ), + apiModelId: row.previous_api_model_id, + }, + current: { + model: modelState( + row.current_model_id, + row.current_canonical_slug, + row.current_marketing_name, + ), + snapshot: snapshotState( + row.current_snapshot_id, + row.current_snapshot_value, + ), + endpoint: endpointState( + row.current_endpoint_id, + row.current_endpoint_path, + row.current_endpoint_base_url, + row.current_endpoint_hostname, + ), + apiModelId: row.current_api_model_id, + }, + previousRecordId: row.previous_record_id, + currentRecordId: row.current_record_id, + previousSource: archiveSourceView({ + source_id: row.previous_source_id, + source_type: row.previous_source_type, + source_url: row.previous_source_url, + source_title: row.previous_source_title, + source_author: row.previous_source_author, + source_published_at: row.previous_source_published_at, + source_retrieved_at: row.previous_source_retrieved_at, + source_content_sha256: row.previous_source_content_sha256, + }), + currentSource, + }; + }); + } + async listRuns(input: { readonly modelId?: string; readonly testCaseId?: string; @@ -941,6 +1230,7 @@ export class PgArchiveRepository { relationResult, aliasResult, bindingResult, + identityDrift, coverageResult, allRuns, ] = await Promise.all([ @@ -1143,6 +1433,7 @@ export class PgArchiveRepository { ORDER BY meb.valid_from DESC, meb.created_at DESC`, [modelId], ), + this.listCatalogChanges({ modelId, limit: 100 }), this.pool.query<{ test_case_id: string; family_slug: string; @@ -1500,6 +1791,7 @@ export class PgArchiveRepository { aliasResolutions, executionBindings, identityTimeline, + identityDrift, testCoverage: coverageResult.rows.map((coverage) => ({ testCaseId: coverage.test_case_id, familySlug: coverage.family_slug, From 142fb0353bf6d14d4db3753512b7f2a88e2e6b61 Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:29:04 +0800 Subject: [PATCH 04/26] database: validate alias identity provider consistency --- .../0008_catalog_identity_drift.sql | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/database/migrations/0008_catalog_identity_drift.sql b/packages/database/migrations/0008_catalog_identity_drift.sql index 4d4231d..4ec98e3 100644 --- a/packages/database/migrations/0008_catalog_identity_drift.sql +++ b/packages/database/migrations/0008_catalog_identity_drift.sql @@ -6,6 +6,59 @@ CREATE TRIGGER alias_resolution_events_append_only BEFORE UPDATE OR DELETE ON alias_resolution_events FOR EACH ROW EXECUTE FUNCTION prevent_append_only_mutation(); +CREATE OR REPLACE FUNCTION validate_alias_resolution_identity() +RETURNS trigger LANGUAGE plpgsql AS $ +DECLARE + alias_provider uuid; + resolved_model_provider uuid; + snapshot_model uuid; + snapshot_provider uuid; +BEGIN + SELECT provider_id INTO alias_provider + FROM model_aliases + WHERE id = NEW.alias_id; + + IF alias_provider IS NULL THEN + RAISE EXCEPTION 'alias resolution references a missing alias'; + END IF; + + IF NEW.resolved_model_id IS NOT NULL THEN + SELECT provider_id INTO resolved_model_provider + FROM models + WHERE id = NEW.resolved_model_id; + + IF resolved_model_provider IS NULL + OR resolved_model_provider <> alias_provider THEN + RAISE EXCEPTION 'alias resolution model provider mismatch'; + END IF; + END IF; + + IF NEW.resolved_snapshot_id IS NOT NULL THEN + SELECT ms.model_id, m.provider_id + INTO snapshot_model, snapshot_provider + FROM model_snapshots ms + JOIN models m ON m.id = ms.model_id + WHERE ms.id = NEW.resolved_snapshot_id; + + IF snapshot_model IS NULL + OR snapshot_provider <> alias_provider THEN + RAISE EXCEPTION 'alias resolution snapshot provider mismatch'; + END IF; + + IF NEW.resolved_model_id IS NOT NULL + AND NEW.resolved_model_id <> snapshot_model THEN + RAISE EXCEPTION 'alias resolution model/snapshot mismatch'; + END IF; + END IF; + + RETURN NEW; +END; +$; + +CREATE TRIGGER alias_resolution_events_validate_identity +BEFORE INSERT ON alias_resolution_events +FOR EACH ROW EXECUTE FUNCTION validate_alias_resolution_identity(); + CREATE OR REPLACE FUNCTION protect_model_execution_binding_history() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN From 5e589c27033b6e25daec4e0f0eb6ce94deb4cddb Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:29:32 +0800 Subject: [PATCH 05/26] api: expose public catalog identity drift feed --- apps/api/src/app.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1750075..03b22ac 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -16,6 +16,7 @@ import type { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PROVIDER_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; type RunApiRepository = Pick; type ControlQueue = Pick; @@ -29,6 +30,7 @@ type ArchiveRepository = Pick< | "listModels" | "listTests" | "listRuns" + | "listCatalogChanges" | "getRun" | "getModel" | "getTest" @@ -164,6 +166,39 @@ export function createApp(deps: AppDependencies) { return c.json({ tests: await deps.archive.listTests() }); }); + app.get("/v1/archive/changes", async (c) => { + if (!deps.archive) { + return c.json({ error: "archive_unavailable" }, 503); + } + + const modelId = c.req.query("modelId"); + const provider = c.req.query("provider"); + const rawLimit = c.req.query("limit"); + + if (modelId && !UUID_RE.test(modelId)) { + return c.json({ error: "invalid_model_id" }, 400); + } + if (provider && !PROVIDER_SLUG_RE.test(provider)) { + return c.json({ error: "invalid_provider" }, 400); + } + + const limit = rawLimit === undefined ? undefined : Number(rawLimit); + if ( + limit !== undefined && + (!Number.isInteger(limit) || limit < 1 || limit > 100) + ) { + return c.json({ error: "invalid_limit" }, 400); + } + + return c.json({ + changes: await deps.archive.listCatalogChanges({ + ...(modelId ? { modelId } : {}), + ...(provider ? { providerSlug: provider } : {}), + ...(limit !== undefined ? { limit } : {}), + }), + }); + }); + app.get("/v1/archive/models/:modelId", async (c) => { if (!deps.archive) { return c.json({ error: "archive_unavailable" }, 503); From 4bff56d809e3eca988fe09282e0431db33b42334 Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:29:56 +0800 Subject: [PATCH 06/26] api: cover catalog identity drift feed --- apps/api/test/archive-api.test.ts | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/apps/api/test/archive-api.test.ts b/apps/api/test/archive-api.test.ts index 544874e..f6bf5bf 100644 --- a/apps/api/test/archive-api.test.ts +++ b/apps/api/test/archive-api.test.ts @@ -22,6 +22,49 @@ const archiveSource = { contentSha256: null, } as const; +const archiveCatalogChange = { + id: `alias:${ALIAS_EVENT_ID}`, + changeType: "alias_target_changed", + occurredAt: "2026-09-02T00:00:00.000Z", + provider: { + id: "00000000-0000-4000-8000-000000000024", + slug: "deepseek", + name: "DeepSeek", + }, + alias: { + id: ALIAS_ID, + value: "deepseek-flash", + }, + changedFields: ["snapshot"], + previous: { + model: { + id: MODEL_ID, + canonicalSlug: "deepseek-flash", + marketingName: "DeepSeek Flash", + }, + snapshot: null, + endpoint: null, + apiModelId: null, + }, + current: { + model: { + id: MODEL_ID, + canonicalSlug: "deepseek-flash", + marketingName: "DeepSeek Flash", + }, + snapshot: { + id: "00000000-0000-4000-8000-000000000034", + providerSnapshotId: "deepseek-flash-202609", + }, + endpoint: null, + apiModelId: null, + }, + previousRecordId: "00000000-0000-4000-8000-000000000042", + currentRecordId: ALIAS_EVENT_ID, + previousSource: archiveSource, + currentSource: archiveSource, +} as const; + function baseRuns() { return { @@ -259,6 +302,7 @@ const archiveModelDetail = { snapshotId: null, }, ], + identityDrift: [archiveCatalogChange], testCoverage: [ { testCaseId: TEST_CASE_ID, @@ -444,6 +488,7 @@ describe("Archive read API", () => { }, ], listRuns: async () => [archiveRun], + listCatalogChanges: async () => [archiveCatalogChange], getRun: async (runId) => (runId === RUN_ID ? archiveRun : null), getModel: async (modelId) => modelId === MODEL_ID ? archiveModelDetail : null, @@ -471,6 +516,22 @@ describe("Archive read API", () => { ], }); + const changes = await app.request( + `/v1/archive/changes?modelId=${MODEL_ID}&provider=deepseek&limit=10`, + ); + expect(changes.status).toBe(200); + await expect(changes.json()).resolves.toMatchObject({ + changes: [ + { + changeType: "alias_target_changed", + changedFields: ["snapshot"], + current: { + snapshot: { providerSnapshotId: "deepseek-flash-202609" }, + }, + }, + ], + }); + const model = await app.request("/v1/archive/models/" + MODEL_ID); expect(model.status).toBe(200); await expect(model.json()).resolves.toMatchObject({ @@ -530,6 +591,7 @@ describe("Archive read API", () => { listModels: async () => [], listTests: async () => [], listRuns: async () => [], + listCatalogChanges: async () => [], getRun: async () => null, getModel: async () => null, getTest: async () => null, @@ -544,6 +606,15 @@ describe("Archive read API", () => { expect( (await app.request("/v1/archive/runs?limit=101")).status, ).toBe(400); + expect( + (await app.request("/v1/archive/changes?modelId=nope")).status, + ).toBe(400); + expect( + (await app.request("/v1/archive/changes?provider=Bad.Provider")).status, + ).toBe(400); + expect( + (await app.request("/v1/archive/changes?limit=101")).status, + ).toBe(400); expect( (await app.request("/v1/archive/models/nope")).status, ).toBe(400); From c31c0f6cd99533584ef6d910fab75916bd7a3073 Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:30:25 +0800 Subject: [PATCH 07/26] web: add catalog drift Archive contracts --- apps/web/src/modelapse.ts | 51 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/web/src/modelapse.ts b/apps/web/src/modelapse.ts index 79cfc58..7bf8c4e 100644 --- a/apps/web/src/modelapse.ts +++ b/apps/web/src/modelapse.ts @@ -82,6 +82,47 @@ export interface ArchiveSource { readonly contentSha256: string | null; } +export interface ArchiveCatalogIdentityState { + readonly model: { + readonly id: string; + readonly canonicalSlug: string; + readonly marketingName: string; + } | null; + readonly snapshot: { + readonly id: string; + readonly providerSnapshotId: string; + } | null; + readonly endpoint: { + readonly id: string; + readonly path: string; + readonly baseUrl: string; + readonly hostname: string; + } | null; + readonly apiModelId: string | null; +} + +export interface ArchiveCatalogChange { + readonly id: string; + readonly changeType: "alias_target_changed" | "execution_binding_changed"; + readonly occurredAt: string; + readonly provider: { + readonly id: string; + readonly slug: string; + readonly name: string; + }; + readonly alias: { + readonly id: string; + readonly value: string; + } | null; + readonly changedFields: readonly string[]; + readonly previous: ArchiveCatalogIdentityState; + readonly current: ArchiveCatalogIdentityState; + readonly previousRecordId: string; + readonly currentRecordId: string; + readonly previousSource: ArchiveSource | null; + readonly currentSource: ArchiveSource; +} + export interface ArchiveModelAliasResolution { readonly id: string; readonly alias: { @@ -220,6 +261,7 @@ export interface ArchiveModelDetail extends ArchiveModel { readonly aliasResolutions: readonly ArchiveModelAliasResolution[]; readonly executionBindings: readonly ArchiveModelExecutionBinding[]; readonly identityTimeline: readonly ArchiveIdentityTimelineEvent[]; + readonly identityDrift: readonly ArchiveCatalogChange[]; readonly testCoverage: readonly { readonly testCaseId: string; readonly familySlug: string; @@ -786,6 +828,15 @@ export const getArchiveRun = createServerFn({ method: "POST" }) } }); +export const getArchiveCatalogChanges = createServerFn({ method: "GET" }).handler( + async (): Promise => { + const result = await requestJson<{ changes: readonly ArchiveCatalogChange[] }>( + "/v1/archive/changes?limit=100", + ); + return result.changes; + }, +); + export const getArchiveCatalog = createServerFn({ method: "GET" }).handler( async (): Promise => { const [models, tests] = await Promise.all([ From 4ecea8cccd090536f051ec3d7486fa2b9dcfc14e Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:31:22 +0800 Subject: [PATCH 08/26] web: add public catalog drift feed --- apps/web/src/routes/changes.tsx | 252 ++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 apps/web/src/routes/changes.tsx diff --git a/apps/web/src/routes/changes.tsx b/apps/web/src/routes/changes.tsx new file mode 100644 index 0000000..ece59b8 --- /dev/null +++ b/apps/web/src/routes/changes.tsx @@ -0,0 +1,252 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { + getArchiveCatalog, + getArchiveCatalogChanges, + type ArchiveCatalogChange, + type ArchiveCatalogIdentityState, +} from "../modelapse"; + +export const Route = createFileRoute("/changes")({ + loader: async () => { + const [changes, catalog] = await Promise.all([ + getArchiveCatalogChanges(), + getArchiveCatalog(), + ]); + return { changes, catalog }; + }, + component: CatalogChangesPage, +}); + +function formatTimestamp(value: string): string { + return new Date(value).toISOString().replace("T", " ").replace(".000Z", "Z"); +} + +function safeSourceHref(value: string | null): string | null { + if (!value) return null; + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? parsed.toString() + : null; + } catch { + return null; + } +} + +function stateLabel(state: ArchiveCatalogIdentityState): string { + const pieces = [ + state.model?.marketingName ?? state.model?.canonicalSlug ?? null, + state.apiModelId, + state.snapshot?.providerSnapshotId ?? null, + state.endpoint?.hostname ?? null, + ].filter((value): value is string => Boolean(value)); + return pieces.length ? pieces.join(" · ") : "—"; +} + +function changeSubject(change: ArchiveCatalogChange): string { + if (change.alias) return `alias ${change.alias.value}`; + return ( + change.current.model?.canonicalSlug ?? + change.previous.model?.canonicalSlug ?? + "execution binding" + ); +} + +function CatalogChangesPage() { + const { changes, catalog } = Route.useLoaderData(); + const [provider, setProvider] = useState(""); + const [modelId, setModelId] = useState(""); + const [kind, setKind] = useState(""); + + const providers = useMemo( + () => + [...new Map( + catalog.models.map((model) => [model.provider.slug, model.provider]), + ).values()].sort((a, b) => a.slug.localeCompare(b.slug)), + [catalog.models], + ); + + const filtered = useMemo( + () => + changes.filter((change) => { + if (provider && change.provider.slug !== provider) return false; + if ( + modelId && + change.previous.model?.id !== modelId && + change.current.model?.id !== modelId + ) { + return false; + } + if (kind && change.changeType !== kind) return false; + return true; + }), + [changes, provider, modelId, kind], + ); + + return ( +
+
+ + M + + Modelapse + AI Model Test & Evolution Archive + + + +
+ +
+
+ Archive + / + Catalog changes +
+
+
+

CATALOG CHANGE DETECTION

+

Identity drift

+

+ Derived transitions between chronological, source-backed catalog facts. +

+
+
+ {changes.length} recent change(s) +
+
+
+ +
+
+
+

FILTERS

+

Inspect the change feed

+
+

+ Drift is derived from immutable alias observations and closed execution-binding + intervals. It is not a new canonical identity claim. +

+
+ +
+ + + +
+ +
+ {filtered.map((change) => { + const sourceHref = safeSourceHref(change.currentSource.url); + const model = + change.current.model ?? + change.previous.model; + return ( +
+
+ {change.changeType.replaceAll("_", " ")} + {changeSubject(change)} + {formatTimestamp(change.occurredAt)} + {change.provider.name} +
+ {change.changedFields.map((field) => ( + {field.replaceAll("_", " ")} + ))} +
+
+ +
+ Before + {stateLabel(change.previous)} + {change.previous.model ? ( + + {change.previous.model.canonicalSlug} → + + ) : null} +
+ + + +
+ After + {stateLabel(change.current)} + {model ? ( + + {model.canonicalSlug} → + + ) : null} +
+ +
+ Current source + + {change.currentSource.title ?? + change.currentSource.url ?? + change.currentSource.sourceType} + + + retrieved {formatTimestamp(change.currentSource.retrievedAt)} + + {sourceHref ? ( + + Source ↗ + + ) : null} +
+
+ ); + })} + + {filtered.length === 0 ? ( +
+ No source-backed identity drift matches these filters. +
+ ) : null} +
+
+ +
+ Modelapse + identity drift = derived change, not rewritten history +
+
+ ); +} From a15c61dd0f66433e012bf06fafd7842f0bcd24c3 Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:31:45 +0800 Subject: [PATCH 09/26] web: show detected identity drift on Model detail --- apps/web/src/routes/models.$modelId.tsx | 77 +++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/apps/web/src/routes/models.$modelId.tsx b/apps/web/src/routes/models.$modelId.tsx index 9107ab4..46a0cd7 100644 --- a/apps/web/src/routes/models.$modelId.tsx +++ b/apps/web/src/routes/models.$modelId.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { getArchiveModel, + type ArchiveCatalogIdentityState, type ArchiveRun, type ArchiveTimelineEvent, } from "../modelapse"; @@ -38,6 +39,16 @@ function shortHash(value: string | null): string { return `${value.slice(0, 14)}…${value.slice(-8)}`; } +function driftStateLabel(state: ArchiveCatalogIdentityState): string { + const parts = [ + state.model?.canonicalSlug ?? null, + state.apiModelId, + state.snapshot?.providerSnapshotId ?? null, + state.endpoint?.hostname ?? null, + ].filter((value): value is string => Boolean(value)); + return parts.length ? parts.join(" · ") : "—"; +} + function evaluationLabel(run: ArchiveRun): string { if (!run.evaluation) return "not evaluated"; if (run.evaluation.exactMatch === true) return "exact match"; @@ -96,6 +107,7 @@ function ArchiveModelPage() { @@ -305,6 +317,71 @@ function ArchiveModelPage() { +
+
+
+

IDENTITY DRIFT

+

Detected catalog transitions

+
+
+ Derived from consecutive source-backed observations.{" "} + Open global change feed → +
+
+ +
+ {model.identityDrift.map((change) => { + const sourceHref = safeSourceHref(change.currentSource.url); + return ( +
+
+ {change.changeType.replaceAll("_", " ")} + {change.alias ? `alias ${change.alias.value}` : model.canonicalSlug} + {formatTimestamp(change.occurredAt)} +
+ {change.changedFields.map((field) => ( + {field.replaceAll("_", " ")} + ))} +
+
+
+ Before + {driftStateLabel(change.previous)} +
+ +
+ After + {driftStateLabel(change.current)} +
+
+ Current source + + {change.currentSource.title ?? + change.currentSource.url ?? + change.currentSource.sourceType} + + {sourceHref ? ( + + Source ↗ + + ) : null} +
+
+ ); + })} + {model.identityDrift.length === 0 ? ( +
+ No source-backed identity transition has been detected for this model. +
+ ) : null} +
+
+
From 48330a4b0614662dc497a82495d9ac6abc796f9a Mon Sep 17 00:00:00 2001 From: WangEn Date: Fri, 25 Sep 2026 10:31:58 +0800 Subject: [PATCH 10/26] web: link public Archive change feed --- apps/web/src/routes/index.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index c1fe30d..cd6b169 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -393,9 +393,14 @@ function ModelapseHome() {

Recent sealed runs

- - Compare models → - +