From 7fbe1f74e619e73dffd2936b35fe421a63715df9 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:44:05 +0800 Subject: [PATCH 01/20] Archive v0.8: add catalog discovery and reconciliation persistence --- .../0010_catalog_discovery_reconciliation.sql | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/database/migrations/0010_catalog_discovery_reconciliation.sql diff --git a/packages/database/migrations/0010_catalog_discovery_reconciliation.sql b/packages/database/migrations/0010_catalog_discovery_reconciliation.sql new file mode 100644 index 0000000..b6e1943 --- /dev/null +++ b/packages/database/migrations/0010_catalog_discovery_reconciliation.sql @@ -0,0 +1,84 @@ +BEGIN; + +SET search_path TO modelapse, public; + +CREATE TABLE catalog_discovery_candidates ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + provider_id uuid NOT NULL REFERENCES providers(id), + remote_model_id text NOT NULL, + first_seen_at timestamptz NOT NULL, + last_seen_at timestamptz NOT NULL, + first_source_record_id uuid NOT NULL REFERENCES source_records(id), + last_source_record_id uuid NOT NULL REFERENCES source_records(id), + first_collection_run_id uuid NOT NULL REFERENCES catalog_collection_runs(id), + last_collection_run_id uuid NOT NULL REFERENCES catalog_collection_runs(id), + latest_provider_snapshot_id text, + observation_count bigint NOT NULL DEFAULT 1 CHECK (observation_count >= 1), + status text NOT NULL DEFAULT 'discovered' + CHECK (status IN ('discovered', 'matched', 'ignored', 'promotion_ready')), + resolved_model_id uuid REFERENCES models(id), + resolved_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (provider_id, remote_model_id), + CHECK (remote_model_id = btrim(remote_model_id) AND remote_model_id <> ''), + CHECK (last_seen_at >= first_seen_at), + CHECK ( + (status = 'matched' AND resolved_model_id IS NOT NULL AND resolved_at IS NOT NULL) + OR + (status <> 'matched') + ) +); + +CREATE TABLE catalog_discovery_observations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + candidate_id uuid NOT NULL REFERENCES catalog_discovery_candidates(id), + collection_run_id uuid NOT NULL REFERENCES catalog_collection_runs(id), + source_record_id uuid NOT NULL REFERENCES source_records(id), + observed_at timestamptz NOT NULL, + provider_snapshot_id text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (candidate_id, collection_run_id) +); + +CREATE TABLE catalog_reconciliation_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + candidate_id uuid NOT NULL REFERENCES catalog_discovery_candidates(id), + action text NOT NULL + CHECK (action IN ('match_existing', 'ignore', 'mark_promotion_ready', 'reopen')), + resolved_model_id uuid REFERENCES models(id), + decided_at timestamptz NOT NULL DEFAULT now(), + actor text NOT NULL CHECK (btrim(actor) <> ''), + note text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK ( + (action = 'match_existing' AND resolved_model_id IS NOT NULL) + OR + (action <> 'match_existing' AND resolved_model_id IS NULL) + ) +); + +CREATE TRIGGER catalog_discovery_observations_append_only +BEFORE UPDATE OR DELETE ON catalog_discovery_observations +FOR EACH ROW EXECUTE FUNCTION prevent_append_only_mutation(); + +CREATE TRIGGER catalog_reconciliation_events_append_only +BEFORE UPDATE OR DELETE ON catalog_reconciliation_events +FOR EACH ROW EXECUTE FUNCTION prevent_append_only_mutation(); + +CREATE INDEX catalog_discovery_candidates_status_seen_idx + ON catalog_discovery_candidates (status, last_seen_at DESC); + +CREATE INDEX catalog_discovery_candidates_provider_seen_idx + ON catalog_discovery_candidates (provider_id, last_seen_at DESC); + +CREATE INDEX catalog_discovery_observations_candidate_seen_idx + ON catalog_discovery_observations (candidate_id, observed_at DESC); + +CREATE INDEX catalog_reconciliation_events_candidate_decided_idx + ON catalog_reconciliation_events (candidate_id, decided_at DESC, id DESC); + +COMMIT; From 8d58281d6ed050ab6b28b698d2654cca69e271d6 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:45:07 +0800 Subject: [PATCH 02/20] Archive v0.8: implement discovery candidate reconciliation --- .../catalog-admin/src/catalog-discovery.ts | 481 ++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 packages/catalog-admin/src/catalog-discovery.ts diff --git a/packages/catalog-admin/src/catalog-discovery.ts b/packages/catalog-admin/src/catalog-discovery.ts new file mode 100644 index 0000000..623d33a --- /dev/null +++ b/packages/catalog-admin/src/catalog-discovery.ts @@ -0,0 +1,481 @@ +import { Pool, type PoolClient } from "pg"; +import type { ObservedRemoteModel } from "./catalog-observer.js"; + +export type CatalogDiscoveryStatus = + | "discovered" + | "matched" + | "ignored" + | "promotion_ready"; + +export type CatalogReconciliationAction = + | "match_existing" + | "ignore" + | "mark_promotion_ready" + | "reopen"; + +export interface CatalogDiscoveryCandidate { + readonly id: string; + readonly provider: { + readonly id: string; + readonly slug: string; + readonly name: string; + }; + readonly remoteModelId: string; + readonly firstSeenAt: string; + readonly lastSeenAt: string; + readonly latestProviderSnapshotId: string | null; + readonly observationCount: number; + readonly status: CatalogDiscoveryStatus; + readonly resolvedModel: { + readonly id: string; + readonly canonicalSlug: string; + readonly marketingName: string; + } | null; + readonly resolvedAt: string | null; + readonly lastSource: { + readonly id: string; + readonly sourceType: string; + readonly url: string | null; + readonly title: string | null; + readonly retrievedAt: string; + readonly contentSha256: string | null; + }; + readonly latestDecision: { + readonly id: string; + readonly action: CatalogReconciliationAction; + readonly decidedAt: string; + readonly actor: string; + readonly note: string | null; + } | null; +} + +function normalizedTime(value: string | undefined, name: string): string { + if (!value) return new Date().toISOString(); + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) { + throw new Error(name + " must be an ISO-8601 timestamp"); + } + return parsed.toISOString(); +} + +function nonEmpty(value: string, name: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(name + " must be non-empty"); + return normalized; +} + +async function lockCandidate( + client: PoolClient, + candidateId: string, +): Promise<{ + id: string; + provider_id: string; + remote_model_id: string; + status: CatalogDiscoveryStatus; +}> { + const result = await client.query<{ + id: string; + provider_id: string; + remote_model_id: string; + status: CatalogDiscoveryStatus; + }>( + `SELECT id, provider_id, remote_model_id, status + FROM modelapse.catalog_discovery_candidates + WHERE id = $1 + FOR UPDATE`, + [candidateId], + ); + const candidate = result.rows[0]; + if (!candidate) throw new Error("Catalog discovery candidate not found"); + return candidate; +} + +export class PgCatalogDiscovery { + constructor(private readonly pool: Pool) {} + + static connect( + connectionString: string, + options: { readonly max?: number } = {}, + ): PgCatalogDiscovery { + return new PgCatalogDiscovery( + new Pool({ + connectionString, + max: options.max ?? 2, + }), + ); + } + + async close(): Promise { + await this.pool.end(); + } + + async recordUnmatchedRemoteModels(input: { + readonly providerId: string; + readonly collectionRunId: string; + readonly sourceRecordId: string; + readonly observedAt: string; + readonly remoteModels: readonly ObservedRemoteModel[]; + readonly matchedRemoteModelIds: ReadonlySet; + }): Promise { + const observedAt = normalizedTime(input.observedAt, "observedAt"); + const unmatched = input.remoteModels.filter( + (model) => !input.matchedRemoteModelIds.has(model.id), + ); + if (unmatched.length === 0) return []; + + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const candidateIds: string[] = []; + + for (const remote of unmatched) { + const remoteModelId = nonEmpty(remote.id, "remote model id"); + const insertedCandidate = await client.query<{ id: string }>( + `INSERT INTO modelapse.catalog_discovery_candidates + ( + provider_id, + remote_model_id, + first_seen_at, + last_seen_at, + first_source_record_id, + last_source_record_id, + first_collection_run_id, + last_collection_run_id, + latest_provider_snapshot_id, + observation_count + ) + VALUES ($1, $2, $3, $3, $4, $4, $5, $5, $6, 1) + ON CONFLICT (provider_id, remote_model_id) DO NOTHING + RETURNING id`, + [ + input.providerId, + remoteModelId, + observedAt, + input.sourceRecordId, + input.collectionRunId, + remote.providerSnapshotId, + ], + ); + + let candidateId = insertedCandidate.rows[0]?.id; + let newlyCreated = Boolean(candidateId); + + if (!candidateId) { + const existing = await client.query<{ id: string }>( + `SELECT id + FROM modelapse.catalog_discovery_candidates + WHERE provider_id = $1 + AND remote_model_id = $2 + FOR UPDATE`, + [input.providerId, remoteModelId], + ); + candidateId = existing.rows[0]?.id; + } + if (!candidateId) { + throw new Error("Catalog discovery candidate could not be resolved"); + } + + const observation = await client.query<{ id: string }>( + `INSERT INTO modelapse.catalog_discovery_observations + ( + candidate_id, + collection_run_id, + source_record_id, + observed_at, + provider_snapshot_id + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (candidate_id, collection_run_id) DO NOTHING + RETURNING id`, + [ + candidateId, + input.collectionRunId, + input.sourceRecordId, + observedAt, + remote.providerSnapshotId, + ], + ); + + if (!observation.rows[0] && newlyCreated) { + throw new Error( + "New catalog discovery candidate is missing its first observation", + ); + } + + if (observation.rows[0] && !newlyCreated) { + await client.query( + `UPDATE modelapse.catalog_discovery_candidates + SET last_seen_at = GREATEST(last_seen_at, $2::timestamptz), + last_source_record_id = $3, + last_collection_run_id = $4, + latest_provider_snapshot_id = + COALESCE($5, latest_provider_snapshot_id), + observation_count = observation_count + 1, + updated_at = now() + WHERE id = $1`, + [ + candidateId, + observedAt, + input.sourceRecordId, + input.collectionRunId, + remote.providerSnapshotId, + ], + ); + } + + candidateIds.push(candidateId); + } + + await client.query("COMMIT"); + return candidateIds; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async listCandidates(input: { + readonly providerSlug?: string; + readonly status?: CatalogDiscoveryStatus; + readonly limit?: number; + } = {}): Promise { + const limit = input.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > 200) { + throw new Error( + "Catalog discovery limit must be an integer between 1 and 200", + ); + } + + const result = await this.pool.query<{ + id: string; + provider_id: string; + provider_slug: string; + provider_name: string; + remote_model_id: string; + first_seen_at: Date; + last_seen_at: Date; + latest_provider_snapshot_id: string | null; + observation_count: string; + status: CatalogDiscoveryStatus; + resolved_model_id: string | null; + resolved_canonical_slug: string | null; + resolved_marketing_name: string | null; + resolved_at: Date | null; + source_id: string; + source_type: string; + source_url: string | null; + source_title: string | null; + source_retrieved_at: Date; + source_content_sha256: string | null; + decision_id: string | null; + decision_action: CatalogReconciliationAction | null; + decision_decided_at: Date | null; + decision_actor: string | null; + decision_note: string | null; + }>( + `SELECT + candidate.id, + provider.id AS provider_id, + provider.slug AS provider_slug, + provider.name AS provider_name, + candidate.remote_model_id, + candidate.first_seen_at, + candidate.last_seen_at, + candidate.latest_provider_snapshot_id, + candidate.observation_count::text, + candidate.status, + model.id AS resolved_model_id, + model.canonical_slug AS resolved_canonical_slug, + model.marketing_name AS resolved_marketing_name, + candidate.resolved_at, + source.id AS source_id, + source.source_type, + source.url AS source_url, + source.title AS source_title, + source.retrieved_at AS source_retrieved_at, + source.content_sha256 AS source_content_sha256, + decision.id AS decision_id, + decision.action AS decision_action, + decision.decided_at AS decision_decided_at, + decision.actor AS decision_actor, + decision.note AS decision_note + FROM modelapse.catalog_discovery_candidates candidate + JOIN modelapse.providers provider + ON provider.id = candidate.provider_id + JOIN modelapse.source_records source + ON source.id = candidate.last_source_record_id + LEFT JOIN modelapse.models model + ON model.id = candidate.resolved_model_id + LEFT JOIN LATERAL ( + SELECT event.* + FROM modelapse.catalog_reconciliation_events event + WHERE event.candidate_id = candidate.id + ORDER BY event.decided_at DESC, event.id DESC + LIMIT 1 + ) decision ON true + WHERE ($1::text IS NULL OR provider.slug = $1) + AND ($2::text IS NULL OR candidate.status = $2) + ORDER BY candidate.last_seen_at DESC, candidate.id + LIMIT $3`, + [ + input.providerSlug?.trim() || null, + input.status ?? null, + limit, + ], + ); + + return result.rows.map((row) => ({ + id: row.id, + provider: { + id: row.provider_id, + slug: row.provider_slug, + name: row.provider_name, + }, + remoteModelId: row.remote_model_id, + firstSeenAt: row.first_seen_at.toISOString(), + lastSeenAt: row.last_seen_at.toISOString(), + latestProviderSnapshotId: row.latest_provider_snapshot_id, + observationCount: Number(row.observation_count), + status: row.status, + resolvedModel: + row.resolved_model_id && + row.resolved_canonical_slug && + row.resolved_marketing_name + ? { + id: row.resolved_model_id, + canonicalSlug: row.resolved_canonical_slug, + marketingName: row.resolved_marketing_name, + } + : null, + resolvedAt: row.resolved_at?.toISOString() ?? null, + lastSource: { + id: row.source_id, + sourceType: row.source_type, + url: row.source_url, + title: row.source_title, + retrievedAt: row.source_retrieved_at.toISOString(), + contentSha256: row.source_content_sha256, + }, + latestDecision: + row.decision_id && + row.decision_action && + row.decision_decided_at && + row.decision_actor + ? { + id: row.decision_id, + action: row.decision_action, + decidedAt: row.decision_decided_at.toISOString(), + actor: row.decision_actor, + note: row.decision_note, + } + : null, + })); + } + + async reconcileCandidate(input: { + readonly candidateId: string; + readonly action: CatalogReconciliationAction; + readonly resolvedModelId?: string; + readonly actor: string; + readonly note?: string; + readonly decidedAt?: string; + }): Promise<{ readonly eventId: string; readonly status: CatalogDiscoveryStatus }> { + const actor = nonEmpty(input.actor, "actor"); + const note = input.note?.trim() || null; + const decidedAt = normalizedTime(input.decidedAt, "decidedAt"); + const resolvedModelId = input.resolvedModelId?.trim() || null; + + if (input.action === "match_existing" && !resolvedModelId) { + throw new Error("match_existing requires resolvedModelId"); + } + if (input.action !== "match_existing" && resolvedModelId) { + throw new Error(input.action + " does not accept resolvedModelId"); + } + + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + "SELECT pg_advisory_xact_lock(hashtext($1))", + ["modelapse:catalog-discovery:" + input.candidateId], + ); + + const candidate = await lockCandidate(client, input.candidateId); + let status: CatalogDiscoveryStatus; + let modelId: string | null = null; + + if (input.action === "match_existing") { + const model = await client.query<{ id: string; provider_id: string }>( + `SELECT id, provider_id + FROM modelapse.models + WHERE id = $1`, + [resolvedModelId], + ); + const resolved = model.rows[0]; + if (!resolved) throw new Error("Resolved model not found"); + if (resolved.provider_id !== candidate.provider_id) { + throw new Error( + "Catalog discovery candidate and resolved model must share a provider", + ); + } + status = "matched"; + modelId = resolved.id; + } else if (input.action === "ignore") { + status = "ignored"; + } else if (input.action === "mark_promotion_ready") { + status = "promotion_ready"; + } else { + status = "discovered"; + } + + const event = await client.query<{ id: string }>( + `INSERT INTO modelapse.catalog_reconciliation_events + ( + candidate_id, + action, + resolved_model_id, + decided_at, + actor, + note, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id`, + [ + candidate.id, + input.action, + modelId, + decidedAt, + actor, + note, + JSON.stringify({ + previousStatus: candidate.status, + remoteModelId: candidate.remote_model_id, + }), + ], + ); + const eventId = event.rows[0]?.id; + if (!eventId) throw new Error("Catalog reconciliation event insert failed"); + + await client.query( + `UPDATE modelapse.catalog_discovery_candidates + SET status = $2, + resolved_model_id = $3, + resolved_at = CASE WHEN $2 = 'matched' THEN $4 ELSE NULL END, + updated_at = now() + WHERE id = $1`, + [candidate.id, status, modelId, decidedAt], + ); + + await client.query("COMMIT"); + return { eventId, status }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} From 0cd64ecd6870e439c50f5ac641091ad6bfa924b6 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:45:35 +0800 Subject: [PATCH 03/20] Archive v0.8: add catalog source adapter interface --- packages/catalog-admin/src/catalog-adapter.ts | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 packages/catalog-admin/src/catalog-adapter.ts diff --git a/packages/catalog-admin/src/catalog-adapter.ts b/packages/catalog-admin/src/catalog-adapter.ts new file mode 100644 index 0000000..5c2287b --- /dev/null +++ b/packages/catalog-admin/src/catalog-adapter.ts @@ -0,0 +1,106 @@ +export type CatalogObserverSourceKind = "model_list" | "docs"; +export type CatalogObserverParser = "openai_models" | "snapshot_only"; + +export interface ObservedRemoteModel { + readonly id: string; + readonly providerSnapshotId: string | null; +} + +export interface CatalogSourceAdapter { + readonly parser: CatalogObserverParser; + requestHeaders(input: { + readonly sourceKind: CatalogObserverSourceKind; + readonly credential: string | undefined; + readonly collectorBuild: string; + }): Readonly>; + parseModelList(body: string): readonly ObservedRemoteModel[] | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalSnapshotId(value: Record): string | null { + for (const key of [ + "provider_snapshot_id", + "providerSnapshotId", + "snapshot", + "version", + "model_version", + ]) { + const raw = value[key]; + if (typeof raw === "string" && raw.trim()) return raw.trim(); + } + return null; +} + +export function parseOpenAICompatibleModelList( + input: unknown, +): readonly ObservedRemoteModel[] { + if (!isRecord(input) || !Array.isArray(input.data)) { + throw new Error("Model-list payload must contain a data array"); + } + + const models = new Map(); + for (const item of input.data) { + if (!isRecord(item) || typeof item.id !== "string" || !item.id.trim()) { + continue; + } + const id = item.id.trim(); + models.set(id, { + id, + providerSnapshotId: optionalSnapshotId(item), + }); + } + return [...models.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + +function defaultHeaders(input: { + readonly sourceKind: CatalogObserverSourceKind; + readonly credential: string | undefined; + readonly collectorBuild: string; +}): Record { + const headers: Record = { + accept: + input.sourceKind === "model_list" + ? "application/json" + : "text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.5", + "user-agent": + "modelapse-catalog-observer/" + input.collectorBuild.slice(0, 64), + }; + if (input.credential) { + headers.authorization = "Bearer " + input.credential; + } + return headers; +} + +const OPENAI_MODELS_ADAPTER: CatalogSourceAdapter = { + parser: "openai_models", + requestHeaders: defaultHeaders, + parseModelList(body) { + return parseOpenAICompatibleModelList(JSON.parse(body)); + }, +}; + +const SNAPSHOT_ONLY_ADAPTER: CatalogSourceAdapter = { + parser: "snapshot_only", + requestHeaders: defaultHeaders, + parseModelList() { + return null; + }, +}; + +const ADAPTERS: Readonly> = { + openai_models: OPENAI_MODELS_ADAPTER, + snapshot_only: SNAPSHOT_ONLY_ADAPTER, +}; + +export function getCatalogSourceAdapter( + parser: CatalogObserverParser, +): CatalogSourceAdapter { + const adapter = ADAPTERS[parser]; + if (!adapter) { + throw new Error("Unsupported catalog source parser: " + parser); + } + return adapter; +} From f623f47fa09bbde5ff4b3a2e68d823d0674f285b Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:45:45 +0800 Subject: [PATCH 04/20] Archive v0.8: share discovery model type through adapter --- packages/catalog-admin/src/catalog-discovery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog-admin/src/catalog-discovery.ts b/packages/catalog-admin/src/catalog-discovery.ts index 623d33a..5b14323 100644 --- a/packages/catalog-admin/src/catalog-discovery.ts +++ b/packages/catalog-admin/src/catalog-discovery.ts @@ -1,5 +1,5 @@ import { Pool, type PoolClient } from "pg"; -import type { ObservedRemoteModel } from "./catalog-observer.js"; +import type { ObservedRemoteModel } from "./catalog-adapter.js"; export type CatalogDiscoveryStatus = | "discovered" From 80f2965aa5351ec4efb9bb04beb5c86d3ae987b5 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:46:15 +0800 Subject: [PATCH 05/20] Archive v0.8: persist discoveries through catalog adapters --- .../catalog-admin/src/catalog-observer.ts | 81 +++++++------------ 1 file changed, 30 insertions(+), 51 deletions(-) diff --git a/packages/catalog-admin/src/catalog-observer.ts b/packages/catalog-admin/src/catalog-observer.ts index 2007e98..d18930d 100644 --- a/packages/catalog-admin/src/catalog-observer.ts +++ b/packages/catalog-admin/src/catalog-observer.ts @@ -1,9 +1,17 @@ import { createHash } from "node:crypto"; import { Pool, type PoolClient } from "pg"; +import { + getCatalogSourceAdapter, + type CatalogObserverParser, + type CatalogObserverSourceKind, +} from "./catalog-adapter.js"; +import { PgCatalogDiscovery } from "./catalog-discovery.js"; import { PgModelCatalogAdmin } from "./model-catalog.js"; -export type CatalogObserverSourceKind = "model_list" | "docs"; -export type CatalogObserverParser = "openai_models" | "snapshot_only"; +export { + parseOpenAICompatibleModelList, + type ObservedRemoteModel, +} from "./catalog-adapter.js"; export type CatalogCollectionStatus = | "succeeded" | "partial" @@ -91,45 +99,6 @@ const DEFAULT_SOURCES = [ }, ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function optionalSnapshotId(value: Record): string | null { - for (const key of [ - "provider_snapshot_id", - "providerSnapshotId", - "snapshot", - "version", - "model_version", - ]) { - const raw = value[key]; - if (typeof raw === "string" && raw.trim()) return raw.trim(); - } - return null; -} - -export function parseOpenAICompatibleModelList( - input: unknown, -): readonly ObservedRemoteModel[] { - if (!isRecord(input) || !Array.isArray(input.data)) { - throw new Error("Model-list payload must contain a data array"); - } - - const models = new Map(); - for (const item of input.data) { - if (!isRecord(item) || typeof item.id !== "string" || !item.id.trim()) { - continue; - } - const id = item.id.trim(); - models.set(id, { - id, - providerSnapshotId: optionalSnapshotId(item), - }); - } - return [...models.values()].sort((a, b) => a.id.localeCompare(b.id)); -} - function normalizedTimestamp(value: string | undefined): string { if (!value) return new Date().toISOString(); const parsed = new Date(value); @@ -244,6 +213,7 @@ export class PgCatalogObserver { private readonly timeoutMs: number; private readonly maxResponseBytes: number; private readonly modelAdmin: PgModelCatalogAdmin; + private readonly discovery: PgCatalogDiscovery; constructor( private readonly pool: Pool, @@ -268,6 +238,7 @@ export class PgCatalogObserver { "Catalog observer maxResponseBytes", ); this.modelAdmin = new PgModelCatalogAdmin(pool); + this.discovery = new PgCatalogDiscovery(pool); } static connect( @@ -596,14 +567,12 @@ export class PgCatalogObserver { let httpStatus: number | null = null; try { - const headers: Record = { - accept: - source.sourceKind === "model_list" - ? "application/json" - : "text/html,application/xhtml+xml,text/plain;q=0.8,*/*;q=0.5", - "user-agent": "modelapse-catalog-observer/" + collectorBuild.slice(0, 64), - }; - if (credential) headers.authorization = "Bearer " + credential; + const adapter = getCatalogSourceAdapter(source.parser); + const headers = adapter.requestHeaders({ + sourceKind: source.sourceKind, + credential, + collectorBuild, + }); const response = await this.fetchImpl(source.url, { method: "GET", @@ -641,7 +610,8 @@ export class PgCatalogObserver { httpStatus: response.status, }); - if (source.parser === "snapshot_only") { + const remoteModels = adapter.parseModelList(body); + if (!remoteModels) { await this.pool.query( `UPDATE modelapse.catalog_observer_sources SET last_succeeded_at = $2, @@ -669,7 +639,6 @@ export class PgCatalogObserver { }; } - const remoteModels = parseOpenAICompatibleModelList(JSON.parse(body)); const remoteById = new Map(remoteModels.map((model) => [model.id, model])); const knownBindings = await this.currentBindings(source.providerId); const missingKnownApiModelIds: string[] = []; @@ -710,6 +679,15 @@ export class PgCatalogObserver { } } + const discoveryCandidateIds = + await this.discovery.recordUnmatchedRemoteModels({ + providerId: source.providerId, + collectionRunId: runId, + sourceRecordId, + observedAt, + remoteModels, + matchedRemoteModelIds: matchedApiModelIds, + }); const unmatchedRemoteModelIds = remoteModels .map((model) => model.id) .filter((id) => !matchedApiModelIds.has(id)) @@ -740,6 +718,7 @@ export class PgCatalogObserver { contentSha256, missingKnownApiModelIds, unmatchedRemoteModelIds, + discoveryCandidateIds, }, }); From df3bf1a1d848e351356dd9e8de40042fc7149244 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:46:59 +0800 Subject: [PATCH 06/20] Archive v0.8: export catalog discovery and adapters --- packages/catalog-admin/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/catalog-admin/src/index.ts b/packages/catalog-admin/src/index.ts index b033c6b..7c8c937 100644 --- a/packages/catalog-admin/src/index.ts +++ b/packages/catalog-admin/src/index.ts @@ -3,3 +3,5 @@ export * from "./openai-smoke.js"; export * from "./direct-smoke.js"; export * from "./model-catalog.js"; export * from "./catalog-observer.js"; +export * from "./catalog-adapter.js"; +export * from "./catalog-discovery.js"; From 8cff1380badaac996d327432a42f22c65fca0e29 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:47:01 +0800 Subject: [PATCH 07/20] Archive v0.8: add discovery reconciliation operator CLI --- packages/catalog-admin/src/cli.ts | 48 +++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/catalog-admin/src/cli.ts b/packages/catalog-admin/src/cli.ts index bc483ed..1a614d2 100644 --- a/packages/catalog-admin/src/cli.ts +++ b/packages/catalog-admin/src/cli.ts @@ -1,5 +1,10 @@ import { FileSystemContentAddressedBlobStore } from "@modelapse/blob-store"; import { PgCatalogAdmin } from "./catalog.js"; +import { + PgCatalogDiscovery, + type CatalogDiscoveryStatus, + type CatalogReconciliationAction, +} from "./catalog-discovery.js"; import { PgCatalogObserver } from "./catalog-observer.js"; import { PgModelCatalogAdmin } from "./model-catalog.js"; @@ -15,14 +20,51 @@ if ( command !== "bootstrap-deepseek-smoke" && command !== "bootstrap-deepseek-flash-model" && command !== "observe-first-party-identity" && - command !== "collect-first-party-catalog" + command !== "collect-first-party-catalog" && + command !== "list-catalog-discoveries" && + command !== "reconcile-catalog-candidate" ) { throw new Error( - "Usage: catalog-admin bootstrap-openai-smoke|bootstrap-deepseek-smoke|bootstrap-deepseek-flash-model|observe-first-party-identity|collect-first-party-catalog", + "Usage: catalog-admin bootstrap-openai-smoke|bootstrap-deepseek-smoke|bootstrap-deepseek-flash-model|observe-first-party-identity|collect-first-party-catalog|list-catalog-discoveries|reconcile-catalog-candidate", ); } -if (command === "collect-first-party-catalog") { +if (command === "list-catalog-discoveries") { + const discovery = PgCatalogDiscovery.connect(requiredEnv("DATABASE_URL")); + try { + const providerSlug = + process.env.MODELAPSE_PROVIDER_SLUG?.trim() || undefined; + const status = + process.env.MODELAPSE_CATALOG_DISCOVERY_STATUS?.trim() as + | CatalogDiscoveryStatus + | undefined; + const result = await discovery.listCandidates({ + ...(providerSlug ? { providerSlug } : {}), + ...(status ? { status } : {}), + }); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } finally { + await discovery.close(); + } +} else if (command === "reconcile-catalog-candidate") { + const discovery = PgCatalogDiscovery.connect(requiredEnv("DATABASE_URL")); + try { + const resolvedModelId = + process.env.MODELAPSE_RESOLVED_MODEL_ID?.trim() || undefined; + const note = + process.env.MODELAPSE_RECONCILIATION_NOTE?.trim() || undefined; + const result = await discovery.reconcileCandidate({ + candidateId: requiredEnv("MODELAPSE_CATALOG_CANDIDATE_ID"), + action: requiredEnv("MODELAPSE_RECONCILIATION_ACTION") as CatalogReconciliationAction, + actor: requiredEnv("MODELAPSE_RECONCILIATION_ACTOR"), + ...(resolvedModelId ? { resolvedModelId } : {}), + ...(note ? { note } : {}), + }); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } finally { + await discovery.close(); + } +} else if (command === "collect-first-party-catalog") { const observer = PgCatalogObserver.connect(requiredEnv("DATABASE_URL")); try { const providerSlug = From e8570e28fe6f38c5f25340a58f21ca7c46649bf7 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:47:04 +0800 Subject: [PATCH 08/20] Archive v0.8: expose discovery operator scripts --- packages/catalog-admin/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/catalog-admin/package.json b/packages/catalog-admin/package.json index e107c4c..106550e 100644 --- a/packages/catalog-admin/package.json +++ b/packages/catalog-admin/package.json @@ -16,7 +16,9 @@ "bootstrap:deepseek-smoke": "node dist/src/cli.js bootstrap-deepseek-smoke", "bootstrap:deepseek-flash-model": "node dist/src/cli.js bootstrap-deepseek-flash-model", "observe:first-party-identity": "node dist/src/cli.js observe-first-party-identity", - "collect:first-party-catalog": "node dist/src/cli.js collect-first-party-catalog" + "collect:first-party-catalog": "node dist/src/cli.js collect-first-party-catalog", + "list:catalog-discoveries": "node dist/src/cli.js list-catalog-discoveries", + "reconcile:catalog-candidate": "node dist/src/cli.js reconcile-catalog-candidate" }, "dependencies": { "@modelapse/blob-store": "*", From 88c95fdadc7073fe41c0b2d668d058d8e459452a Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:47:28 +0800 Subject: [PATCH 09/20] Archive v0.8: enforce reconciliation provider invariants --- .../0010_catalog_discovery_reconciliation.sql | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/database/migrations/0010_catalog_discovery_reconciliation.sql b/packages/database/migrations/0010_catalog_discovery_reconciliation.sql index b6e1943..66b4984 100644 --- a/packages/database/migrations/0010_catalog_discovery_reconciliation.sql +++ b/packages/database/migrations/0010_catalog_discovery_reconciliation.sql @@ -27,7 +27,7 @@ CREATE TABLE catalog_discovery_candidates ( CHECK ( (status = 'matched' AND resolved_model_id IS NOT NULL AND resolved_at IS NOT NULL) OR - (status <> 'matched') + (status <> 'matched' AND resolved_model_id IS NULL AND resolved_at IS NULL) ) ); @@ -61,6 +61,38 @@ CREATE TABLE catalog_reconciliation_events ( ) ); +CREATE OR REPLACE FUNCTION validate_catalog_reconciliation_event() +RETURNS trigger LANGUAGE plpgsql AS $catalog_reconciliation$ +DECLARE + candidate_provider uuid; + model_provider uuid; +BEGIN + SELECT provider_id INTO candidate_provider + FROM catalog_discovery_candidates + WHERE id = NEW.candidate_id; + + IF candidate_provider IS NULL THEN + RAISE EXCEPTION 'catalog reconciliation references a missing candidate'; + END IF; + + IF NEW.action = 'match_existing' THEN + SELECT provider_id INTO model_provider + FROM models + WHERE id = NEW.resolved_model_id; + + IF model_provider IS NULL OR model_provider <> candidate_provider THEN + RAISE EXCEPTION 'catalog reconciliation model provider mismatch'; + END IF; + END IF; + + RETURN NEW; +END; +$catalog_reconciliation$; + +CREATE TRIGGER catalog_reconciliation_events_validate +BEFORE INSERT ON catalog_reconciliation_events +FOR EACH ROW EXECUTE FUNCTION validate_catalog_reconciliation_event(); + CREATE TRIGGER catalog_discovery_observations_append_only BEFORE UPDATE OR DELETE ON catalog_discovery_observations FOR EACH ROW EXECUTE FUNCTION prevent_append_only_mutation(); From 27a99c256ecb182bacf1a3d581b8a4c8193886a7 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:47:39 +0800 Subject: [PATCH 10/20] Archive v0.8: harden discovery chronology and actions --- .../catalog-admin/src/catalog-discovery.ts | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/catalog-admin/src/catalog-discovery.ts b/packages/catalog-admin/src/catalog-discovery.ts index 5b14323..d8afaf4 100644 --- a/packages/catalog-admin/src/catalog-discovery.ts +++ b/packages/catalog-admin/src/catalog-discovery.ts @@ -205,11 +205,34 @@ export class PgCatalogDiscovery { if (observation.rows[0] && !newlyCreated) { await client.query( `UPDATE modelapse.catalog_discovery_candidates - SET last_seen_at = GREATEST(last_seen_at, $2::timestamptz), - last_source_record_id = $3, - last_collection_run_id = $4, + SET first_seen_at = LEAST(first_seen_at, $2::timestamptz), + first_source_record_id = + CASE + WHEN $2::timestamptz < first_seen_at THEN $3 + ELSE first_source_record_id + END, + first_collection_run_id = + CASE + WHEN $2::timestamptz < first_seen_at THEN $4 + ELSE first_collection_run_id + END, + last_source_record_id = + CASE + WHEN $2::timestamptz >= last_seen_at THEN $3 + ELSE last_source_record_id + END, + last_collection_run_id = + CASE + WHEN $2::timestamptz >= last_seen_at THEN $4 + ELSE last_collection_run_id + END, latest_provider_snapshot_id = - COALESCE($5, latest_provider_snapshot_id), + CASE + WHEN $2::timestamptz >= last_seen_at + THEN COALESCE($5, latest_provider_snapshot_id) + ELSE latest_provider_snapshot_id + END, + last_seen_at = GREATEST(last_seen_at, $2::timestamptz), observation_count = observation_count + 1, updated_at = now() WHERE id = $1`, @@ -382,6 +405,14 @@ export class PgCatalogDiscovery { readonly note?: string; readonly decidedAt?: string; }): Promise<{ readonly eventId: string; readonly status: CatalogDiscoveryStatus }> { + if ( + !["match_existing", "ignore", "mark_promotion_ready", "reopen"].includes( + input.action, + ) + ) { + throw new Error("Unsupported catalog reconciliation action: " + input.action); + } + const actor = nonEmpty(input.actor, "actor"); const note = input.note?.trim() || null; const decidedAt = normalizedTime(input.decidedAt, "decidedAt"); From f51c8804e2ce05a22b316c0ef1c7077961d5f853 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:48:09 +0800 Subject: [PATCH 11/20] Archive v0.8: expose parser through adapter module --- packages/catalog-admin/src/catalog-observer.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/catalog-admin/src/catalog-observer.ts b/packages/catalog-admin/src/catalog-observer.ts index d18930d..a26e70c 100644 --- a/packages/catalog-admin/src/catalog-observer.ts +++ b/packages/catalog-admin/src/catalog-observer.ts @@ -8,10 +8,6 @@ import { import { PgCatalogDiscovery } from "./catalog-discovery.js"; import { PgModelCatalogAdmin } from "./model-catalog.js"; -export { - parseOpenAICompatibleModelList, - type ObservedRemoteModel, -} from "./catalog-adapter.js"; export type CatalogCollectionStatus = | "succeeded" | "partial" From a4fc1a35ebfd330ae596ce9192509e89bdcb47b0 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:48:12 +0800 Subject: [PATCH 12/20] Archive v0.8: cover catalog adapter contract --- .../test/catalog-observer.test.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/catalog-admin/test/catalog-observer.test.ts b/packages/catalog-admin/test/catalog-observer.test.ts index 10b834d..271894b 100644 --- a/packages/catalog-admin/test/catalog-observer.test.ts +++ b/packages/catalog-admin/test/catalog-observer.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { parseOpenAICompatibleModelList } from "../src/catalog-observer.js"; +import { + getCatalogSourceAdapter, + parseOpenAICompatibleModelList, +} from "../src/catalog-adapter.js"; describe("catalog observer model-list parser", () => { it("normalizes and deduplicates OpenAI-compatible model lists", () => { @@ -19,6 +22,26 @@ describe("catalog observer model-list parser", () => { ]); }); + + it("uses the adapter contract for request headers and snapshot-only sources", () => { + const models = getCatalogSourceAdapter("openai_models"); + expect( + models.requestHeaders({ + sourceKind: "model_list", + credential: "secret", + collectorBuild: "build-123", + }), + ).toMatchObject({ + accept: "application/json", + authorization: "Bearer secret", + }); + + const docs = getCatalogSourceAdapter("snapshot_only"); + expect( + docs.parseModelList("first-party docs"), + ).toBeNull(); + }); + it("rejects payloads without a data array", () => { expect(() => parseOpenAICompatibleModelList({ models: [] })).toThrow( /data array/, From 7ca4a88f6291e3bf799820d93a3696088d3a1567 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:48:27 +0800 Subject: [PATCH 13/20] Archive v0.8: verify discovery aggregation and reconciliation audit --- .../test/bootstrap.integration.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/packages/catalog-admin/test/bootstrap.integration.test.ts b/packages/catalog-admin/test/bootstrap.integration.test.ts index 2fcd494..2a903b3 100644 --- a/packages/catalog-admin/test/bootstrap.integration.test.ts +++ b/packages/catalog-admin/test/bootstrap.integration.test.ts @@ -10,6 +10,7 @@ import { Pool } from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { PgCatalogAdmin, + PgCatalogDiscovery, PgCatalogObserver, PgModelCatalogAdmin, } from "../src/index.js"; @@ -370,6 +371,156 @@ describe("production catalog bootstrap", () => { unmatchedRemoteModelIds: ["unmapped-remote-model"], }, }); + + const discovery = PgCatalogDiscovery.connect(isolatedDatabaseUrl); + try { + const candidates = await discovery.listCandidates({ + providerSlug: "deepseek", + status: "discovered", + }); + const candidate = candidates.find( + (item) => item.remoteModelId === "unmapped-remote-model", + ); + expect(candidate).toMatchObject({ + provider: { slug: "deepseek" }, + remoteModelId: "unmapped-remote-model", + observationCount: 2, + status: "discovered", + resolvedModel: null, + latestDecision: null, + }); + expect(candidate?.firstSeenAt).toBe("2099-02-01T00:00:00.000Z"); + expect(candidate?.lastSeenAt).toBe("2099-02-02T00:00:00.000Z"); + + if (!candidate) { + throw new Error("Expected unmatched discovery candidate"); + } + + await expect( + discovery.reconcileCandidate({ + candidateId: candidate.id, + action: "match_existing", + resolvedModelId: registered.modelId, + actor: "integration-test", + note: "exact provider reconciliation", + decidedAt: "2099-02-03T00:00:00.000Z", + }), + ).resolves.toMatchObject({ status: "matched" }); + + const matched = await discovery.listCandidates({ + providerSlug: "deepseek", + status: "matched", + }); + expect(matched.find((item) => item.id === candidate.id)).toMatchObject({ + status: "matched", + resolvedModel: { + id: registered.modelId, + canonicalSlug: "deepseek-flash", + }, + latestDecision: { + action: "match_existing", + actor: "integration-test", + }, + }); + + await expect( + discovery.reconcileCandidate({ + candidateId: candidate.id, + action: "ignore", + actor: "integration-test", + note: "reclassified for state-machine coverage", + decidedAt: "2099-02-04T00:00:00.000Z", + }), + ).resolves.toMatchObject({ status: "ignored" }); + + await expect( + discovery.reconcileCandidate({ + candidateId: candidate.id, + action: "mark_promotion_ready", + actor: "integration-test", + decidedAt: "2099-02-05T00:00:00.000Z", + }), + ).resolves.toMatchObject({ status: "promotion_ready" }); + + await expect( + discovery.reconcileCandidate({ + candidateId: candidate.id, + action: "reopen", + actor: "integration-test", + decidedAt: "2099-02-06T00:00:00.000Z", + }), + ).resolves.toMatchObject({ status: "discovered" }); + + const otherProvider = await verification.query<{ id: string }>( + `INSERT INTO modelapse.providers (slug, name) + VALUES ('integration-other-provider', 'Integration Other Provider') + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, + ); + const otherModel = await verification.query<{ id: string }>( + `INSERT INTO modelapse.models + (provider_id, canonical_slug, marketing_name, status) + VALUES ($1, 'other-model', 'Other Model', 'active') + ON CONFLICT (provider_id, canonical_slug) + DO UPDATE SET marketing_name = EXCLUDED.marketing_name + RETURNING id`, + [otherProvider.rows[0]!.id], + ); + + await expect( + discovery.reconcileCandidate({ + candidateId: candidate.id, + action: "match_existing", + resolvedModelId: otherModel.rows[0]!.id, + actor: "integration-test", + }), + ).rejects.toThrow(/share a provider/); + + await expect( + verification.query( + `INSERT INTO modelapse.catalog_reconciliation_events + (candidate_id, action, resolved_model_id, actor) + VALUES ($1, 'match_existing', $2, 'direct-sql-test')`, + [candidate.id, otherModel.rows[0]!.id], + ), + ).rejects.toThrow(/provider mismatch/); + + const decisions = await verification.query<{ id: string }>( + `SELECT id + FROM modelapse.catalog_reconciliation_events + WHERE candidate_id = $1 + ORDER BY decided_at, id`, + [candidate.id], + ); + expect(decisions.rows).toHaveLength(4); + + await expect( + verification.query( + `UPDATE modelapse.catalog_reconciliation_events + SET note = 'rewritten' + WHERE id = $1`, + [decisions.rows[0]!.id], + ), + ).rejects.toThrow(/append-only/); + + const observations = await verification.query<{ id: string }>( + `SELECT id + FROM modelapse.catalog_discovery_observations + WHERE candidate_id = $1 + ORDER BY observed_at, id`, + [candidate.id], + ); + expect(observations.rows).toHaveLength(2); + await expect( + verification.query( + `DELETE FROM modelapse.catalog_discovery_observations + WHERE id = $1`, + [observations.rows[0]!.id], + ), + ).rejects.toThrow(/append-only/); + } finally { + await discovery.close(); + } } finally { await verification.end(); } From 5bd8581eeffa3b1409284a8daf1ec982df830211 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:49:04 +0800 Subject: [PATCH 14/20] Archive v0.8: document catalog discovery reconciliation --- docs/catalog-reconciliation.md | 173 +++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/catalog-reconciliation.md diff --git a/docs/catalog-reconciliation.md b/docs/catalog-reconciliation.md new file mode 100644 index 0000000..1909b71 --- /dev/null +++ b/docs/catalog-reconciliation.md @@ -0,0 +1,173 @@ +# Catalog Discovery / Reconciliation + +Archive v0.8 promotes unmatched first-party model IDs from collection-run diagnostics into an auditable discovery queue. + +The discovery layer does **not** auto-create Models, rewrite execution bindings, or synthesize identity drift. It separates three facts: + +1. a first-party source exposed a remote model ID; +2. Modelapse has or has not reconciled that ID to a canonical Model; +3. a source-backed execution identity observation changed over time. + +Only (3) feeds the v0.6 drift engine. + +## Data model + +### `catalog_discovery_candidates` + +One mutable summary row per `(provider, remote_model_id)`. + +It keeps: + +- first / last seen timestamps; +- first / last collection and source references; +- latest explicit provider snapshot ID when supplied by the source; +- observation count; +- current reconciliation state; +- optional resolved canonical Model. + +Current states: + +```text +discovered +matched +ignored +promotion_ready +``` + +The summary is an index over immutable history; it is not the audit log. + +### `catalog_discovery_observations` + +Append-only evidence that a candidate appeared in a specific first-party collection run. + +Repeated model-list collections therefore answer both: + +- “is this ID still present?” +- “how many independent retrievals have observed it?” + +### `catalog_reconciliation_events` + +Append-only operator decisions: + +```text +match_existing +ignore +mark_promotion_ready +reopen +``` + +A `match_existing` decision must resolve to a Model owned by the same Provider. This is enforced both by the TypeScript repository and a PostgreSQL trigger. + +Reclassification writes another event; prior decisions are never rewritten. + +## Collector integration + +For every parsed model list, v0.8 divides remote IDs into two sets: + +```text +exact current first-party binding match + -> v0.6 observeFirstPartyIdentity(...) + -> alias / binding history and drift + +not an exact current binding match + -> Catalog Discovery Candidate + -> append-only Discovery Observation +``` + +An unmatched ID can remain visible for many collections without becoming canonical identity. + +Collection-run metadata still contains `unmatchedRemoteModelIds` for lightweight diagnostics, and now also records `discoveryCandidateIds` so the run can be joined directly to the durable discovery queue. + +## Reconciliation semantics + +### Match existing + +Use when evidence supports that the remote ID belongs to an already-cataloged Model. + +This records the reconciliation only. It does **not**: + +- add or replace an execution binding; +- insert an alias-resolution event; +- close a previous binding; +- generate a v0.6 drift event. + +Those operations require a source-backed identity observation with the normal v0.6 chronology and immutability rules. + +### Ignore + +Use for IDs that should remain observed evidence but are not useful as a Modelapse canonical Model candidate, for example non-chat utility endpoints or provider-internal artifacts. + +Future collections continue to update first/last-seen evidence without erasing the ignore decision. + +### Promotion ready + +Marks a discovery as ready for a later explicit canonical registration workflow. + +v0.8 deliberately stops before automatic promotion. Canonical Model creation still requires a deliberate registration step with sourced identity fields. + +### Reopen + +Returns any reconciled candidate to `discovered` while preserving every earlier decision event. + +## Catalog adapter boundary + +The HTTP / parsing behavior is now behind `CatalogSourceAdapter`. + +Current adapters: + +- `openai_models`: OpenAI-compatible JSON model-list parsing; +- `snapshot_only`: capture and hash the source without deriving model identities. + +The Observer selects adapters by the source row's `parser` value. Provider/source scheduling remains data-driven in `catalog_observer_sources`. + +This keeps scheduling, retrieval, parsing, discovery, reconciliation, and identity ingestion as separate layers so provider-specific parsing can expand without coupling to the drift engine. + +## Operator CLI + +List candidates: + +```bash +npm run list:catalog-discoveries -w @modelapse/catalog-admin +``` + +Optional filters: + +```text +MODELAPSE_PROVIDER_SLUG=deepseek +MODELAPSE_CATALOG_DISCOVERY_STATUS=discovered +``` + +Reconcile one candidate: + +```bash +npm run reconcile:catalog-candidate -w @modelapse/catalog-admin +``` + +Required: + +```text +DATABASE_URL=... +MODELAPSE_CATALOG_CANDIDATE_ID= +MODELAPSE_RECONCILIATION_ACTION=match_existing|ignore|mark_promotion_ready|reopen +MODELAPSE_RECONCILIATION_ACTOR= +``` + +For `match_existing`: + +```text +MODELAPSE_RESOLVED_MODEL_ID= +``` + +Optional: + +```text +MODELAPSE_RECONCILIATION_NOTE=... +``` + +Operator identity is stored as audit data. Do not put secrets in the actor or note fields. + +## Promotion remains explicit + +A `promotion_ready` candidate is evidence for the next workflow, not a Model. + +A later promotion layer should require an explicit canonical slug, marketing name, status/family/track decisions, source selection, and first-party execution binding before the candidate can become a canonical Model. From 1e22ce5425223f92d605bec227907a53d26c728a Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:49:39 +0800 Subject: [PATCH 15/20] Archive v0.8: index current reconciliation by insertion order --- .../migrations/0010_catalog_discovery_reconciliation.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/database/migrations/0010_catalog_discovery_reconciliation.sql b/packages/database/migrations/0010_catalog_discovery_reconciliation.sql index 66b4984..6a83f22 100644 --- a/packages/database/migrations/0010_catalog_discovery_reconciliation.sql +++ b/packages/database/migrations/0010_catalog_discovery_reconciliation.sql @@ -110,7 +110,7 @@ CREATE INDEX catalog_discovery_candidates_provider_seen_idx CREATE INDEX catalog_discovery_observations_candidate_seen_idx ON catalog_discovery_observations (candidate_id, observed_at DESC); -CREATE INDEX catalog_reconciliation_events_candidate_decided_idx - ON catalog_reconciliation_events (candidate_id, decided_at DESC, id DESC); +CREATE INDEX catalog_reconciliation_events_candidate_created_idx + ON catalog_reconciliation_events (candidate_id, created_at DESC, id DESC); COMMIT; From b97871d3ba124dd06975ede9245785ec383f527d Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:49:42 +0800 Subject: [PATCH 16/20] Archive v0.8: project latest reconciliation by audit insertion --- packages/catalog-admin/src/catalog-discovery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog-admin/src/catalog-discovery.ts b/packages/catalog-admin/src/catalog-discovery.ts index d8afaf4..86a45ea 100644 --- a/packages/catalog-admin/src/catalog-discovery.ts +++ b/packages/catalog-admin/src/catalog-discovery.ts @@ -335,7 +335,7 @@ export class PgCatalogDiscovery { SELECT event.* FROM modelapse.catalog_reconciliation_events event WHERE event.candidate_id = candidate.id - ORDER BY event.decided_at DESC, event.id DESC + ORDER BY event.created_at DESC, event.id DESC LIMIT 1 ) decision ON true WHERE ($1::text IS NULL OR provider.slug = $1) From 118eca4677145f04fab4aae3544d849887a8e2ac Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:49:44 +0800 Subject: [PATCH 17/20] Archive v0.8: bound discovery metadata projection --- packages/catalog-admin/src/catalog-observer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/catalog-admin/src/catalog-observer.ts b/packages/catalog-admin/src/catalog-observer.ts index a26e70c..15816e7 100644 --- a/packages/catalog-admin/src/catalog-observer.ts +++ b/packages/catalog-admin/src/catalog-observer.ts @@ -675,7 +675,7 @@ export class PgCatalogObserver { } } - const discoveryCandidateIds = + const allDiscoveryCandidateIds = await this.discovery.recordUnmatchedRemoteModels({ providerId: source.providerId, collectionRunId: runId, @@ -684,6 +684,7 @@ export class PgCatalogObserver { remoteModels, matchedRemoteModelIds: matchedApiModelIds, }); + const discoveryCandidateIds = allDiscoveryCandidateIds.slice(0, 200); const unmatchedRemoteModelIds = remoteModels .map((model) => model.id) .filter((id) => !matchedApiModelIds.has(id)) @@ -715,6 +716,7 @@ export class PgCatalogObserver { missingKnownApiModelIds, unmatchedRemoteModelIds, discoveryCandidateIds, + discoveryCandidateCount: allDiscoveryCandidateIds.length, }, }); From 2e60d1d4d2a7e9ed8730d70a1c5bb09ef6edc962 Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:50:17 +0800 Subject: [PATCH 18/20] Archive v0.8: avoid duplicate adapter model export --- packages/catalog-admin/src/catalog-observer.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/catalog-admin/src/catalog-observer.ts b/packages/catalog-admin/src/catalog-observer.ts index 15816e7..0ec5a80 100644 --- a/packages/catalog-admin/src/catalog-observer.ts +++ b/packages/catalog-admin/src/catalog-observer.ts @@ -14,11 +14,6 @@ export type CatalogCollectionStatus = | "failed" | "skipped"; -export interface ObservedRemoteModel { - readonly id: string; - readonly providerSnapshotId: string | null; -} - export interface CatalogCollectionResult { readonly runId: string; readonly sourceKey: string; From 33ee8854c45e0b26830b9d05c166f56e433e6d3e Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:50:20 +0800 Subject: [PATCH 19/20] Archive v0.8: validate discovery filters and ids --- packages/catalog-admin/src/catalog-discovery.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/catalog-admin/src/catalog-discovery.ts b/packages/catalog-admin/src/catalog-discovery.ts index 86a45ea..cd10cfd 100644 --- a/packages/catalog-admin/src/catalog-discovery.ts +++ b/packages/catalog-admin/src/catalog-discovery.ts @@ -264,6 +264,15 @@ export class PgCatalogDiscovery { readonly status?: CatalogDiscoveryStatus; readonly limit?: number; } = {}): Promise { + if ( + input.status && + !["discovered", "matched", "ignored", "promotion_ready"].includes( + input.status, + ) + ) { + throw new Error("Unsupported catalog discovery status: " + input.status); + } + const limit = input.limit ?? 100; if (!Number.isInteger(limit) || limit < 1 || limit > 200) { throw new Error( @@ -413,6 +422,7 @@ export class PgCatalogDiscovery { throw new Error("Unsupported catalog reconciliation action: " + input.action); } + const candidateId = nonEmpty(input.candidateId, "candidateId"); const actor = nonEmpty(input.actor, "actor"); const note = input.note?.trim() || null; const decidedAt = normalizedTime(input.decidedAt, "decidedAt"); @@ -430,10 +440,10 @@ export class PgCatalogDiscovery { await client.query("BEGIN"); await client.query( "SELECT pg_advisory_xact_lock(hashtext($1))", - ["modelapse:catalog-discovery:" + input.candidateId], + ["modelapse:catalog-discovery:" + candidateId], ); - const candidate = await lockCandidate(client, input.candidateId); + const candidate = await lockCandidate(client, candidateId); let status: CatalogDiscoveryStatus; let modelId: string | null = null; From a701245c4d82778cedad9e161069dcbfc10947cd Mon Sep 17 00:00:00 2001 From: WangEn Date: Thu, 1 Oct 2026 10:53:53 +0800 Subject: [PATCH 20/20] Archive v0.8: type reconciliation timestamp assignment --- packages/catalog-admin/src/catalog-discovery.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/catalog-admin/src/catalog-discovery.ts b/packages/catalog-admin/src/catalog-discovery.ts index cd10cfd..f936d07 100644 --- a/packages/catalog-admin/src/catalog-discovery.ts +++ b/packages/catalog-admin/src/catalog-discovery.ts @@ -504,7 +504,11 @@ export class PgCatalogDiscovery { `UPDATE modelapse.catalog_discovery_candidates SET status = $2, resolved_model_id = $3, - resolved_at = CASE WHEN $2 = 'matched' THEN $4 ELSE NULL END, + resolved_at = + CASE + WHEN $2 = 'matched' THEN $4::timestamptz + ELSE NULL::timestamptz + END, updated_at = now() WHERE id = $1`, [candidate.id, status, modelId, decidedAt],