diff --git a/apps/api/package.json b/apps/api/package.json index 32706bd..88a1fe3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,8 @@ "@hono/node-server": "2.1.1", "@modelapse/control-plane": "*", "@modelapse/persistence": "*", - "hono": "4.13.8" + "hono": "4.13.8", + "@modelapse/catalog-admin": "*" }, "devDependencies": { "vitest": "3.0.8" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 03b22ac..a229e1e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -13,6 +13,10 @@ import type { RunRepository, RunView, } from "@modelapse/persistence"; +import type { + CatalogDiscoveryStatus, + PgCatalogDiscovery, +} from "@modelapse/catalog-admin"; 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; @@ -24,6 +28,11 @@ type ControlPlanner = Pick< PgRunPlanner, "ping" | "listModels" | "listTests" | "plan" >; +type CatalogDiscoveryRepository = Pick< + PgCatalogDiscovery, + "listCandidates" | "listProviderModels" | "reconcileCandidate" | "promoteCandidate" +>; + type ArchiveRepository = Pick< PgArchiveRepository, | "ping" @@ -43,6 +52,7 @@ export interface AppDependencies { readonly jobs?: ControlQueue; readonly planner?: ControlPlanner; readonly archive?: ArchiveRepository; + readonly catalogDiscovery?: CatalogDiscoveryRepository; readonly controlToken?: string; } @@ -372,6 +382,175 @@ export function createApp(deps: AppDependencies) { return c.json({ run: publicRun(run) }); }); + app.get("/v1/control/catalog/discoveries", async (c) => { + if (!deps.catalogDiscovery || !controlToken) { + return c.json({ error: "control_plane_disabled" }, 503); + } + const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken); + if (authIssue) return c.json(controlAuthError(authIssue), 401); + + const provider = c.req.query("provider"); + const status = c.req.query("status") as CatalogDiscoveryStatus | undefined; + const rawLimit = c.req.query("limit"); + if (provider && !PROVIDER_SLUG_RE.test(provider)) { + return c.json({ error: "invalid_provider" }, 400); + } + if ( + status && + !["discovered", "matched", "ignored", "promotion_ready"].includes(status) + ) { + return c.json({ error: "invalid_discovery_status" }, 400); + } + const limit = rawLimit === undefined ? undefined : Number(rawLimit); + if ( + limit !== undefined && + (!Number.isInteger(limit) || limit < 1 || limit > 200) + ) { + return c.json({ error: "invalid_limit" }, 400); + } + + return c.json({ + candidates: await deps.catalogDiscovery.listCandidates({ + ...(provider ? { providerSlug: provider } : {}), + ...(status ? { status } : {}), + ...(limit !== undefined ? { limit } : {}), + }), + }); + }); + + app.get("/v1/control/catalog/providers/:providerId/models", async (c) => { + if (!deps.catalogDiscovery || !controlToken) { + return c.json({ error: "control_plane_disabled" }, 503); + } + const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken); + if (authIssue) return c.json(controlAuthError(authIssue), 401); + + const providerId = c.req.param("providerId"); + if (!UUID_RE.test(providerId)) { + return c.json({ error: "invalid_provider_id" }, 400); + } + return c.json({ + models: await deps.catalogDiscovery.listProviderModels(providerId), + }); + }); + + app.post("/v1/control/catalog/discoveries/:candidateId/reconcile", async (c) => { + if (!deps.catalogDiscovery || !controlToken) { + return c.json({ error: "control_plane_disabled" }, 503); + } + const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken); + if (authIssue) return c.json(controlAuthError(authIssue), 401); + + const candidateId = c.req.param("candidateId"); + if (!UUID_RE.test(candidateId)) { + return c.json({ error: "invalid_candidate_id" }, 400); + } + let raw: unknown; + try { + raw = await c.req.json(); + } catch { + return c.json({ error: "invalid_json" }, 400); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return c.json({ error: "invalid_reconciliation" }, 400); + } + const body = raw as Record; + const action = body.action; + const actor = body.actor; + const resolvedModelId = body.resolvedModelId; + const note = body.note; + if ( + typeof action !== "string" || + !["match_existing", "ignore", "mark_promotion_ready", "reopen"].includes(action) || + typeof actor !== "string" || + !actor.trim() || + (resolvedModelId !== undefined && + (typeof resolvedModelId !== "string" || !UUID_RE.test(resolvedModelId))) || + (note !== undefined && typeof note !== "string") + ) { + return c.json({ error: "invalid_reconciliation" }, 400); + } + + try { + const result = await deps.catalogDiscovery.reconcileCandidate({ + candidateId, + action: action as "match_existing" | "ignore" | "mark_promotion_ready" | "reopen", + actor, + ...(typeof resolvedModelId === "string" ? { resolvedModelId } : {}), + ...(typeof note === "string" ? { note } : {}), + }); + return c.json(result); + } catch (error) { + return c.json( + { + error: "reconciliation_rejected", + message: error instanceof Error ? error.message : "Reconciliation rejected", + }, + 409, + ); + } + }); + + app.post("/v1/control/catalog/discoveries/:candidateId/promote", async (c) => { + if (!deps.catalogDiscovery || !controlToken) { + return c.json({ error: "control_plane_disabled" }, 503); + } + const authIssue = controlAuthIssue(c.req.header("authorization"), controlToken); + if (authIssue) return c.json(controlAuthError(authIssue), 401); + + const candidateId = c.req.param("candidateId"); + if (!UUID_RE.test(candidateId)) { + return c.json({ error: "invalid_candidate_id" }, 400); + } + let raw: unknown; + try { + raw = await c.req.json(); + } catch { + return c.json({ error: "invalid_json" }, 400); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return c.json({ error: "invalid_promotion" }, 400); + } + const body = raw as Record; + const canonicalSlug = body.canonicalSlug; + const marketingName = body.marketingName; + const status = body.status; + const actor = body.actor; + const note = body.note; + if ( + typeof canonicalSlug !== "string" || + !PROVIDER_SLUG_RE.test(canonicalSlug) || + typeof marketingName !== "string" || + !marketingName.trim() || + (status !== undefined && status !== "preview" && status !== "active") || + typeof actor !== "string" || + !actor.trim() || + (note !== undefined && typeof note !== "string") + ) { + return c.json({ error: "invalid_promotion" }, 400); + } + + try { + const result = await deps.catalogDiscovery.promoteCandidate({ + candidateId, + canonicalSlug, + marketingName, + ...(status === "preview" || status === "active" ? { status } : {}), + actor, + ...(typeof note === "string" ? { note } : {}), + }); + return c.json(result, 201); + } catch (error) { + return c.json( + { + error: "promotion_rejected", + message: error instanceof Error ? error.message : "Promotion rejected", + }, + 409, + ); + } + }); + app.get("/v1/control/catalog/models", async (c) => { if (!deps.planner || !controlToken) { return c.json({ error: "control_plane_disabled" }, 503); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5b8e6be..958330f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,4 +1,5 @@ import { serve } from "@hono/node-server"; +import { PgCatalogDiscovery } from "@modelapse/catalog-admin"; import { PgRunJobQueue, PgRunPlanner, @@ -18,6 +19,7 @@ const runs = PgRunRepository.connect(databaseUrl); const jobs = PgRunJobQueue.connect(databaseUrl); const planner = PgRunPlanner.connect(databaseUrl); const archive = PgArchiveRepository.connect(databaseUrl); +const catalogDiscovery = PgCatalogDiscovery.connect(databaseUrl); const controlToken = process.env.MODELAPSE_CONTROL_TOKEN; const port = Number(process.env.PORT ?? "3000"); const app = createApp({ @@ -25,6 +27,7 @@ const app = createApp({ jobs, planner, archive, + catalogDiscovery, ...(controlToken ? { controlToken } : {}), }); @@ -46,6 +49,7 @@ function shutdown(signal: string): void { jobs.close(), planner.close(), archive.close(), + catalogDiscovery.close(), ]).finally(() => { if (error) { console.error(error); diff --git a/apps/api/test/control-plane.test.ts b/apps/api/test/control-plane.test.ts index dfcfbb2..86418fb 100644 --- a/apps/api/test/control-plane.test.ts +++ b/apps/api/test/control-plane.test.ts @@ -119,3 +119,107 @@ describe("Run job control API", () => { }); }); }); + + +describe("Catalog Discovery control API", () => { + const candidateId = "00000000-0000-4000-8000-000000000003"; + + function candidate() { + return { + id: candidateId, + provider: { id: "p", slug: "deepseek", name: "DeepSeek" }, + remoteModelId: "deepseek-next", + firstSeenAt: "2026-10-01T00:00:00.000Z", + lastSeenAt: "2026-10-02T00:00:00.000Z", + latestProviderSnapshotId: null, + observationCount: 2, + status: "discovered" as const, + resolvedModel: null, + resolvedAt: null, + lastSource: { + id: "s", + sourceType: "provider_api", + url: "https://api.deepseek.com/models", + title: "DeepSeek Models API", + retrievedAt: "2026-10-02T00:00:00.000Z", + contentSha256: "a".repeat(64), + }, + promotion: null, + latestDecision: null, + }; + } + + it("keeps discovery reads and writes behind control authentication", async () => { + let reconciled = false; + let promoted = false; + const app = createApp({ + runs: baseRuns(), + controlToken: "control-secret", + catalogDiscovery: { + listCandidates: async () => [candidate()], + listProviderModels: async () => [], + reconcileCandidate: async () => { + reconciled = true; + return { eventId: "event", status: "promotion_ready" }; + }, + promoteCandidate: async () => { + promoted = true; + return { + candidateId, + modelId: "00000000-0000-4000-8000-000000000004", + promotionEventId: "promotion", + reconciliationEventId: "reconciliation", + }; + }, + }, + }); + + const unauthorized = await app.request("/v1/control/catalog/discoveries"); + expect(unauthorized.status).toBe(401); + + const listed = await app.request( + "/v1/control/catalog/discoveries?status=discovered", + { headers: { authorization: "Bearer control-secret" } }, + ); + expect(listed.status).toBe(200); + await expect(listed.json()).resolves.toMatchObject({ + candidates: [{ remoteModelId: "deepseek-next" }], + }); + + const reconciledResponse = await app.request( + `/v1/control/catalog/discoveries/${candidateId}/reconcile`, + { + method: "POST", + headers: { + authorization: "Bearer control-secret", + "content-type": "application/json", + }, + body: JSON.stringify({ + action: "mark_promotion_ready", + actor: "web-operator", + }), + }, + ); + expect(reconciledResponse.status).toBe(200); + expect(reconciled).toBe(true); + + const promotedResponse = await app.request( + `/v1/control/catalog/discoveries/${candidateId}/promote`, + { + method: "POST", + headers: { + authorization: "Bearer control-secret", + "content-type": "application/json", + }, + body: JSON.stringify({ + canonicalSlug: "deepseek-next", + marketingName: "DeepSeek Next", + status: "active", + actor: "web-operator", + }), + }, + ); + expect(promotedResponse.status).toBe(201); + expect(promoted).toBe(true); + }); +}); diff --git a/apps/web/src/modelapse.ts b/apps/web/src/modelapse.ts index 7bf8c4e..123b2c2 100644 --- a/apps/web/src/modelapse.ts +++ b/apps/web/src/modelapse.ts @@ -438,6 +438,47 @@ export interface ArchiveTemporalComparison { }[]; } +export type CatalogDiscoveryStatus = + | "discovered" + | "matched" + | "ignored" + | "promotion_ready"; + +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: ArchiveSource; + readonly promotion: { + readonly id: string; + readonly promotedAt: string; + readonly actor: string; + readonly modelId: string; + } | null; + readonly latestDecision: { + readonly id: string; + readonly action: "match_existing" | "ignore" | "mark_promotion_ready" | "reopen"; + readonly decidedAt: string; + readonly actor: string; + readonly note: string | null; + } | null; +} + export interface ControlJob { readonly id: string; readonly kind: string; @@ -476,6 +517,29 @@ interface ReadJobInput extends OperatorInput { readonly jobId: string; } +interface CatalogInboxInput extends OperatorInput { + readonly status?: CatalogDiscoveryStatus; +} + +interface ReconcileCatalogCandidateInput extends OperatorInput { + readonly candidateId: string; + readonly action: "match_existing" | "ignore" | "mark_promotion_ready" | "reopen"; + readonly resolvedModelId?: string; + readonly note?: string; +} + +interface CatalogProviderModelsInput extends OperatorInput { + readonly providerId: string; +} + +interface PromoteCatalogCandidateInput extends OperatorInput { + readonly candidateId: string; + readonly canonicalSlug: string; + readonly marketingName: string; + readonly status: "preview" | "active"; + readonly note?: string; +} + interface ReadArchiveRunInput { readonly runId: string; } @@ -677,6 +741,111 @@ function parseReadJobInput(value: unknown): ReadJobInput { return { operatorToken, jobId }; } +function parseCatalogInboxInput(value: unknown): CatalogInboxInput { + const operatorToken = parseOperatorToken(value); + if (!isRecord(value)) throw new Error("Catalog Inbox request must be an object"); + const status = value.status; + if ( + status !== undefined && + status !== "discovered" && + status !== "matched" && + status !== "ignored" && + status !== "promotion_ready" + ) { + throw new Error("Invalid catalog discovery status"); + } + return { operatorToken, ...(status ? { status } : {}) }; +} + +function parseReconcileCatalogCandidateInput( + value: unknown, +): ReconcileCatalogCandidateInput { + const operatorToken = parseOperatorToken(value); + if (!isRecord(value)) throw new Error("Reconciliation request must be an object"); + const candidateId = value.candidateId; + const action = value.action; + const note = value.note; + const resolvedModelId = value.resolvedModelId; + if (typeof candidateId !== "string" || !UUID_RE.test(candidateId)) { + throw new Error("candidateId must be a UUID"); + } + if ( + action !== "match_existing" && + action !== "ignore" && + action !== "mark_promotion_ready" && + action !== "reopen" + ) { + throw new Error("Unsupported Inbox reconciliation action"); + } + if (note !== undefined && typeof note !== "string") { + throw new Error("note must be a string"); + } + if ( + resolvedModelId !== undefined && + (typeof resolvedModelId !== "string" || !UUID_RE.test(resolvedModelId)) + ) { + throw new Error("resolvedModelId must be a UUID"); + } + if (action === "match_existing" && typeof resolvedModelId !== "string") { + throw new Error("match_existing requires resolvedModelId"); + } + return { + operatorToken, + candidateId, + action, + ...(typeof resolvedModelId === "string" ? { resolvedModelId } : {}), + ...(typeof note === "string" ? { note } : {}), + }; +} + +function parseCatalogProviderModelsInput(value: unknown): CatalogProviderModelsInput { + const operatorToken = parseOperatorToken(value); + if (!isRecord(value)) throw new Error("Provider models request must be an object"); + const providerId = value.providerId; + if (typeof providerId !== "string" || !UUID_RE.test(providerId)) { + throw new Error("providerId must be a UUID"); + } + return { operatorToken, providerId }; +} + +function parsePromoteCatalogCandidateInput( + value: unknown, +): PromoteCatalogCandidateInput { + const operatorToken = parseOperatorToken(value); + if (!isRecord(value)) throw new Error("Promotion request must be an object"); + const candidateId = value.candidateId; + const canonicalSlug = value.canonicalSlug; + const marketingName = value.marketingName; + const status = value.status; + const note = value.note; + if (typeof candidateId !== "string" || !UUID_RE.test(candidateId)) { + throw new Error("candidateId must be a UUID"); + } + if ( + typeof canonicalSlug !== "string" || + !/^[a-z0-9][a-z0-9-]*$/.test(canonicalSlug) + ) { + throw new Error("canonicalSlug must use lowercase letters, digits, and hyphens"); + } + if (typeof marketingName !== "string" || !marketingName.trim()) { + throw new Error("marketingName is required"); + } + if (status !== "preview" && status !== "active") { + throw new Error("status must be preview or active"); + } + if (note !== undefined && typeof note !== "string") { + throw new Error("note must be a string"); + } + return { + operatorToken, + candidateId, + canonicalSlug, + marketingName, + status, + ...(typeof note === "string" ? { note } : {}), + }; +} + function parseArchiveRunInput(value: unknown): ReadArchiveRunInput { if (!isRecord(value)) throw new Error("Archive Run request must be an object"); @@ -964,6 +1133,80 @@ export const compareArchive = createServerFn({ method: "POST" }) } }); +export const getCatalogInbox = createServerFn({ method: "POST" }) + .validator(parseCatalogInboxInput) + .handler(async ({ data }): Promise => { + requireOperator(data.operatorToken); + const params = new URLSearchParams({ limit: "200" }); + if (data.status) params.set("status", data.status); + const result = await requestJson<{ + candidates: readonly CatalogDiscoveryCandidate[]; + }>(`/v1/control/catalog/discoveries?${params.toString()}`, { + control: true, + }); + return result.candidates; + }); + +export const getCatalogProviderModels = createServerFn({ method: "POST" }) + .validator(parseCatalogProviderModelsInput) + .handler(async ({ data }) => { + requireOperator(data.operatorToken); + const result = await requestJson<{ + models: readonly { + id: string; + canonicalSlug: string; + marketingName: string; + status: string; + }[]; + }>(`/v1/control/catalog/providers/${data.providerId}/models`, { + control: true, + }); + return result.models; + }); + +export const reconcileCatalogCandidate = createServerFn({ method: "POST" }) + .validator(parseReconcileCatalogCandidateInput) + .handler(async ({ data }) => { + requireOperator(data.operatorToken); + return requestJson<{ eventId: string; status: CatalogDiscoveryStatus }>( + `/v1/control/catalog/discoveries/${data.candidateId}/reconcile`, + { + method: "POST", + control: true, + body: { + action: data.action, + actor: "web-operator", + ...(data.resolvedModelId + ? { resolvedModelId: data.resolvedModelId } + : {}), + ...(data.note ? { note: data.note } : {}), + }, + }, + ); + }); + +export const promoteCatalogCandidate = createServerFn({ method: "POST" }) + .validator(parsePromoteCatalogCandidateInput) + .handler(async ({ data }) => { + requireOperator(data.operatorToken); + return requestJson<{ + candidateId: string; + modelId: string; + promotionEventId: string; + reconciliationEventId: string; + }>(`/v1/control/catalog/discoveries/${data.candidateId}/promote`, { + method: "POST", + control: true, + body: { + canonicalSlug: data.canonicalSlug, + marketingName: data.marketingName, + status: data.status, + actor: "web-operator", + ...(data.note ? { note: data.note } : {}), + }, + }); + }); + export const getControlCatalog = createServerFn({ method: "POST" }) .validator(parseOperatorInput) .handler(async ({ data }): Promise => { diff --git a/apps/web/src/routes/catalog-inbox.tsx b/apps/web/src/routes/catalog-inbox.tsx new file mode 100644 index 0000000..886c454 --- /dev/null +++ b/apps/web/src/routes/catalog-inbox.tsx @@ -0,0 +1,451 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { + getCatalogInbox, + getCatalogProviderModels, + promoteCatalogCandidate, + reconcileCatalogCandidate, + type CatalogDiscoveryCandidate, + type CatalogDiscoveryStatus, +} from "../modelapse"; + +export const Route = createFileRoute("/catalog-inbox")({ + component: CatalogInbox, +}); + +function suggestedSlug(remoteModelId: string): string { + return remoteModelId + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +function suggestedName(remoteModelId: string): string { + return remoteModelId + .split(/[-_.:/]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatTimestamp(value: string): string { + return value.replace("T", " ").slice(0, 19) + "Z"; +} + +function CatalogInbox() { + const [operatorToken, setOperatorToken] = useState(""); + const [candidates, setCandidates] = useState([]); + const [filter, setFilter] = useState("discovered"); + const [selectedId, setSelectedId] = useState(null); + const [canonicalSlug, setCanonicalSlug] = useState(""); + const [marketingName, setMarketingName] = useState(""); + const [modelStatus, setModelStatus] = useState<"preview" | "active">("active"); + const [providerModels, setProviderModels] = useState< + readonly { id: string; canonicalSlug: string; marketingName: string; status: string }[] + >([]); + const [resolvedModelId, setResolvedModelId] = useState(""); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const selected = useMemo( + () => candidates.find((candidate) => candidate.id === selectedId) ?? null, + [candidates, selectedId], + ); + + async function loadInbox(status = filter): Promise { + if (!operatorToken) return; + setBusy(true); + setError(null); + try { + const result = await getCatalogInbox({ + data: { + operatorToken, + ...(status === "all" ? {} : { status }), + }, + }); + setCandidates(result); + if (selectedId && !result.some((candidate) => candidate.id === selectedId)) { + setSelectedId(null); + } + } catch (cause) { + setCandidates([]); + setError(cause instanceof Error ? cause.message : "Catalog Inbox request failed"); + } finally { + setBusy(false); + } + } + + async function selectCandidate(candidate: CatalogDiscoveryCandidate): Promise { + setSelectedId(candidate.id); + setCanonicalSlug(suggestedSlug(candidate.remoteModelId)); + setMarketingName(suggestedName(candidate.remoteModelId)); + setModelStatus("active"); + setNote(""); + setMessage(null); + setError(null); + setResolvedModelId(""); + try { + const models = await getCatalogProviderModels({ + data: { operatorToken, providerId: candidate.provider.id }, + }); + setProviderModels(models); + } catch { + setProviderModels([]); + } + } + + async function reconcile( + action: "match_existing" | "ignore" | "mark_promotion_ready" | "reopen", + ): Promise { + if (!selected) return; + setBusy(true); + setError(null); + setMessage(null); + try { + await reconcileCatalogCandidate({ + data: { + operatorToken, + candidateId: selected.id, + action, + ...(action === "match_existing" && resolvedModelId + ? { resolvedModelId } + : {}), + ...(note ? { note } : {}), + }, + }); + setMessage( + action === "mark_promotion_ready" + ? "Candidate marked promotion ready. Review canonical identity fields before promotion." + : action === "ignore" + ? "Candidate ignored; observation history remains intact." + : "Candidate reopened for review.", + ); + await loadInbox(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Reconciliation failed"); + } finally { + setBusy(false); + } + } + + async function promote(): Promise { + if (!selected) return; + setBusy(true); + setError(null); + setMessage(null); + try { + const result = await promoteCatalogCandidate({ + data: { + operatorToken, + candidateId: selected.id, + canonicalSlug, + marketingName, + status: modelStatus, + ...(note ? { note } : {}), + }, + }); + setMessage(`Promoted to canonical Model ${result.modelId}. The promotion and reconciliation events are immutable.`); + await loadInbox(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Promotion failed"); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + M + + Modelapse + Catalog Discovery Inbox + + + Public catalog changes → +
+ +
+
+

OPERATOR / ARCHIVE v0.9

+

+ Discovery is evidence. +
+ Promotion is a decision. +

+

+ Review remote model IDs observed from first-party catalog sources. + Reconcile or promote them without rewriting the immutable collection + history that discovered them. +

+
+
+ +
+
+
+

ACCESS

+

Unlock Catalog Inbox

+
+ + {candidates.length ? `${candidates.length} loaded` : "operator locked"} + +
+
+ + +
+ +
+
+ {error ?
{error}
: null} + {message ?
{message}
: null} +
+ +
+
+
+

DISCOVERY QUEUE

+

First-party remote IDs

+
+
+ +
+
+ {candidates.map((candidate) => ( + + ))} + {candidates.length === 0 ? ( +
+ Unlock the Inbox, or no candidates match this state. +
+ ) : null} +
+ +