Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
00edf2b
database: derive source-backed catalog identity drift
WangEn Sep 25, 2026
6b2db90
catalog: add chronological first-party identity observer
WangEn Sep 25, 2026
147daa3
archive: project derived catalog identity drift
WangEn Sep 25, 2026
142fb03
database: validate alias identity provider consistency
WangEn Sep 25, 2026
5e589c2
api: expose public catalog identity drift feed
WangEn Sep 25, 2026
4bff56d
api: cover catalog identity drift feed
WangEn Sep 25, 2026
c31c0f6
web: add catalog drift Archive contracts
WangEn Sep 25, 2026
4ecea8c
web: add public catalog drift feed
WangEn Sep 25, 2026
a15c61d
web: show detected identity drift on Model detail
WangEn Sep 25, 2026
48330a4
web: link public Archive change feed
WangEn Sep 25, 2026
f367fcc
web: style catalog drift feed and transitions
WangEn Sep 25, 2026
19ffbe1
catalog: integration-test identity observation drift
WangEn Sep 25, 2026
c003629
archive: integration-test derived identity drift projections
WangEn Sep 25, 2026
f1efa7e
catalog: detect first-party endpoint transitions atomically
WangEn Sep 25, 2026
f96646d
archive: cover endpoint plus snapshot binding drift
WangEn Sep 25, 2026
1086135
docs: define Archive v0.6 catalog drift semantics
WangEn Sep 25, 2026
2944d49
catalog: compare observation timestamps explicitly
WangEn Sep 25, 2026
8e58882
catalog: expose identity observation CLI
WangEn Sep 25, 2026
8c8b6b6
catalog: add identity observation npm script
WangEn Sep 25, 2026
4d03dd1
docs: document identity observation CLI
WangEn Sep 25, 2026
585003d
database: use explicit dollar tags in drift triggers
WangEn Sep 25, 2026
7f548be
test: match drift projections by relevant fields
WangEn Sep 25, 2026
f0bc032
test: use partial matchers for enriched provenance records
WangEn Sep 25, 2026
6510f95
archive: isolate unordered drift collection assertions
WangEn Sep 25, 2026
3f6ff0c
catalog: serialize observations by model and provider alias
WangEn Sep 25, 2026
93b0ad5
docs: note alias-level observation serialization
WangEn Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunRepository, "ping" | "getRun">;
type ControlQueue = Pick<PgRunJobQueue, "ping" | "enqueue" | "get">;
Expand All @@ -29,6 +30,7 @@ type ArchiveRepository = Pick<
| "listModels"
| "listTests"
| "listRuns"
| "listCatalogChanges"
| "getRun"
| "getModel"
| "getTest"
Expand Down Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions apps/api/test/archive-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -259,6 +302,7 @@ const archiveModelDetail = {
snapshotId: null,
},
],
identityDrift: [archiveCatalogChange],
testCoverage: [
{
testCaseId: TEST_CASE_ID,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/modelapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -786,6 +828,15 @@ export const getArchiveRun = createServerFn({ method: "POST" })
}
});

export const getArchiveCatalogChanges = createServerFn({ method: "GET" }).handler(
async (): Promise<readonly ArchiveCatalogChange[]> => {
const result = await requestJson<{ changes: readonly ArchiveCatalogChange[] }>(
"/v1/archive/changes?limit=100",
);
return result.changes;
},
);

export const getArchiveCatalog = createServerFn({ method: "GET" }).handler(
async (): Promise<ArchiveCatalog> => {
const [models, tests] = await Promise.all([
Expand Down
Loading
Loading