Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ab3796a
Archive v0.27: immutable research collection manifests and public-Run…
WangEn Oct 8, 2026
41fb217
Implement append-only operator-curated bounded research snapshots wit…
WangEn Oct 8, 2026
5ec5f54
Read exact immutable Run identities in saved collection order
WangEn Oct 8, 2026
acd920b
Add sourced JSON/CSV research exports with spreadsheet formula escaping
WangEn Oct 8, 2026
7ad6d58
Export research collection persistence and sourced JSON/CSV helpers
WangEn Oct 8, 2026
d2e7467
Wire public Research Collection repository into API lifecycle
WangEn Oct 8, 2026
c6289c5
Expose immutable research collection list/read/export and operator-on…
WangEn Oct 8, 2026
6d4e71a
Add Web server functions for saving, sharing and downloading immutabl…
WangEn Oct 8, 2026
d9ce033
Add public saved research collection gallery
WangEn Oct 8, 2026
3bc7de2
Add verifiable research snapshot detail and JSON/CSV download controls
WangEn Oct 8, 2026
1f03182
Allow operator to publish bounded immutable snapshot from live resear…
WangEn Oct 8, 2026
8df93c1
Test reproducible manifest digest, sourced exports and CSV formula in…
WangEn Oct 8, 2026
7d40ee7
Simplify CSV formula-injection test literal
WangEn Oct 8, 2026
91c5d77
Test PostgreSQL research snapshot save/reopen, immutable manifest and…
WangEn Oct 8, 2026
ce6be72
Test public research collection browsing/export, operator-only captur…
WangEn Oct 8, 2026
ced2f90
Use explicit column alias for PostgreSQL snapshot ID uniqueness check
WangEn Oct 8, 2026
57ea473
Type live research filter URL tuples for snapshot detail
WangEn Oct 8, 2026
1bc26db
Run v0.27 research digest and export tests in CI
WangEn Oct 8, 2026
b3e8c7e
Document Archive v0.27 immutable snapshots and sourced exports
WangEn Oct 8, 2026
abf617d
ADR 0023: freeze research selection IDs, not live query results
WangEn Oct 8, 2026
c648b16
Mark v0.27 immutable collections and sourced exports implemented; out…
WangEn Oct 8, 2026
ba3c119
Allow independent append-only captures of an identical research selec…
WangEn Oct 8, 2026
1edb7a5
Complete strongly typed Run fixture for research export tests
WangEn Oct 8, 2026
f9d4d09
Complete Test identity in strongly typed research export fixture
WangEn Oct 8, 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
107 changes: 106 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import {
type PgRunPlanner,
type RunJob,
} from "@modelapse/control-plane";
import { validateArchiveResearchFilters } from "@modelapse/persistence";
import { exportResearchCollection, validateArchiveResearchFilters } from "@modelapse/persistence";
import type {
ArchiveResearchFilters,
PgArchiveRepository,
PgResearchCollections,
PgCalibrationRepository,
PgComparabilityRepository,
PgCostLedger,
Expand Down Expand Up @@ -108,6 +109,10 @@ type ComparabilityRepository = Pick<
"ping" | "listPolicies" | "recordPolicy"
>;

type ResearchCollectionsRepository = Pick<
PgResearchCollections,
"ping" | "list" | "get" | "create"
>;
type ArchiveRepository = Pick<
PgArchiveRepository,
| "ping"
Expand All @@ -129,6 +134,7 @@ export interface AppDependencies {
readonly fleet?: ControlFleet;
readonly planner?: ControlPlanner;
readonly archive?: ArchiveRepository;
readonly researchCollections?: ResearchCollectionsRepository;
readonly catalogDiscovery?: CatalogDiscoveryRepository;
readonly catalogDriftReview?: CatalogDriftReviewRepository;
readonly catalogIdentityCase?: CatalogIdentityCaseRepository;
Expand Down Expand Up @@ -312,6 +318,105 @@ export function createApp(deps: AppDependencies) {
}
});

app.get("/v1/archive/research/collections", async (c) => {
if (!deps.researchCollections) {
return c.json({ error: "research_collections_unavailable" }, 503);
}
const rawLimit = c.req.query("limit");
const limit = rawLimit === undefined ? 30 : Number(rawLimit);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return c.json({ error: "invalid_limit" }, 400);
}
return c.json({ collections: await deps.researchCollections.list(limit) });
});

app.get("/v1/archive/research/collections/:collectionId", async (c) => {
if (!deps.researchCollections) {
return c.json({ error: "research_collections_unavailable" }, 503);
}
const id = c.req.param("collectionId");
if (!UUID_RE.test(id)) {
return c.json({ error: "invalid_research_collection_id" }, 400);
}
const collection = await deps.researchCollections.get(id);
return collection
? c.json({ collection })
: c.json({ error: "research_collection_not_found" }, 404);
});

app.get("/v1/archive/research/collections/:collectionId/export", async (c) => {
if (!deps.researchCollections) {
return c.json({ error: "research_collections_unavailable" }, 503);
}
const id = c.req.param("collectionId");
if (!UUID_RE.test(id)) {
return c.json({ error: "invalid_research_collection_id" }, 400);
}
const format = c.req.query("format") ?? "json";
if (format !== "csv" && format !== "json") {
return c.json({ error: "invalid_research_export_format" }, 400);
}
const collection = await deps.researchCollections.get(id);
if (!collection) return c.json({ error: "research_collection_not_found" }, 404);
const exported = exportResearchCollection(collection, format);
c.header("Content-Type", exported.mediaType);
c.header("Content-Disposition", `attachment; filename="${exported.filename}"`);
c.header("Cache-Control", "public, max-age=300");
return c.body(exported.body);
});

app.post("/v1/control/research/collections", async (c) => {
if (!deps.researchCollections || !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);
let raw: unknown;
try {
raw = await c.req.json();
} catch {
return c.json({ error: "invalid_json" }, 400);
}
const body = apiRecord(raw);
const filters = apiRecord(body?.filters);
const title = body?.title;
const actor = body?.actor;
const description = body?.description;
if (
typeof title !== "string" ||
typeof actor !== "string" ||
(description !== undefined && typeof description !== "string") ||
!filters ||
Object.keys(filters).some((key) => ![
"providerSlug", "modelId", "testCaseId", "evidence", "region",
"accountTier", "serviceTier", "cost"
].includes(key)) ||
Object.values(filters).some((value) => typeof value !== "string")
) {
return c.json({ error: "invalid_research_collection_request" }, 400);
}
try {
const validatedFilters = validateArchiveResearchFilters(
filters as ArchiveResearchFilters,
);
const collection = await deps.researchCollections.create({
title,
actor,
...(typeof description === "string" ? { description } : {}),
filters: validatedFilters,
});
return c.json({ collection }, 201);
} catch (error) {
if (error instanceof Error && (
error.message.startsWith("invalid_") ||
error.message.startsWith("research_collection_")
)) {
return c.json({ error: error.message }, 400);
}
throw error;
}
});

app.get("/v1/archive/changes", async (c) => {
if (!deps.archive) {
return c.json({ error: "archive_unavailable" }, 503);
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
PgComparabilityRepository,
PgCostLedger,
PgRunRepository,
PgResearchCollections,
} from "@modelapse/persistence";
import { createApp } from "./app.js";

Expand All @@ -35,6 +36,7 @@ const jobs = PgRunJobQueue.connect(databaseUrl);
const fleet = PgExecutionFleet.connect(databaseUrl);
const planner = PgRunPlanner.connect(databaseUrl);
const archive = PgArchiveRepository.connect(databaseUrl);
const researchCollections = PgResearchCollections.connect(databaseUrl, archive);
const costLedger = PgCostLedger.connect(databaseUrl);
const calibration = PgCalibrationRepository.connect(databaseUrl);
const comparability = PgComparabilityRepository.connect(databaseUrl);
Expand All @@ -56,6 +58,7 @@ const app = createApp({
fleet,
planner,
archive,
researchCollections,
costLedger,
calibration,
comparability,
Expand Down Expand Up @@ -91,6 +94,7 @@ function shutdown(signal: string): void {
fleet.close(),
planner.close(),
archive.close(),
researchCollections.close(),
costLedger.close(),
calibration.close(),
comparability.close(),
Expand Down
107 changes: 107 additions & 0 deletions apps/api/test/archive-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,3 +733,110 @@ describe("Archive read API", () => {

});
});


describe("Archive v0.27 immutable research collections API", () => {
it("allows public browsing and export, but requires operator auth for capture", async () => {
const id = "00000000-0000-4000-8000-000000000488";
const collection = {
id,
title: "Source-backed research snapshot",
description: "Immutable selection of one first-party Run",
filters: { modelId: MODEL_ID, evidence: "E4+" as const },
runIds: [RUN_ID],
contentSha256: "a".repeat(64),
createdBy: "test-operator",
createdAt: "2026-10-08T00:00:00.000Z",
selectionLimit: 50 as const,
runs: [archiveRun],
};
let createdInput: unknown;
const app = createApp({
runs: baseRuns(),
controlToken: "control-secret",
researchCollections: {
ping: async () => undefined,
list: async () => [{ ...collection }],
get: async (collectionId) => collectionId === id ? collection : null,
create: async (input) => {
createdInput = input;
return collection;
},
},
});

const listed = await app.request("/v1/archive/research/collections");
expect(listed.status).toBe(200);
await expect(listed.json()).resolves.toMatchObject({
collections: [{ id, title: collection.title, runIds: [RUN_ID] }],
});
const detail = await app.request(
"/v1/archive/research/collections/" + id,
);
expect(detail.status).toBe(200);
await expect(detail.json()).resolves.toMatchObject({
collection: { id, runs: [{ id: RUN_ID }] },
});

const json = await app.request(
"/v1/archive/research/collections/" + id + "/export?format=json",
);
expect(json.status).toBe(200);
expect(json.headers.get("content-disposition")).toContain(".json");
await expect(json.json()).resolves.toMatchObject({
manifest: { id, runIds: [RUN_ID], scope: "sealed_public_non_calibration" },
interpretation: { rankingsProvided: false },
rows: [{ runId: RUN_ID, pricingSourceId: null }],
});
const csv = await app.request(
"/v1/archive/research/collections/" + id + "/export?format=csv",
);
expect(csv.status).toBe(200);
expect(csv.headers.get("content-type")).toContain("text/csv");
expect(await csv.text()).toContain(RUN_ID);

const unauthorized = await app.request("/v1/control/research/collections", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Bad snapshot", actor: "guest", filters: {} }),
});
expect(unauthorized.status).toBe(401);
const authorizedHeaders = {
authorization: "Bearer control-secret",
"content-type": "application/json",
};
const created = await app.request("/v1/control/research/collections", {
method: "POST",
headers: authorizedHeaders,
body: JSON.stringify({
title: "Snapshot from API",
actor: "test-operator",
filters: { modelId: MODEL_ID, evidence: "E4+" },
}),
});
expect(created.status).toBe(201);
expect(createdInput).toMatchObject({
title: "Snapshot from API",
actor: "test-operator",
filters: { modelId: MODEL_ID, evidence: "E4+" },
});

for (const path of [
"/v1/archive/research/collections/nope",
"/v1/archive/research/collections/" + id + "/export?format=html",
"/v1/archive/research/collections?limit=51",
]) {
expect((await app.request(path)).status, path).toBe(400);
}
const invalidCapture = await app.request("/v1/control/research/collections", {
method: "POST",
headers: authorizedHeaders,
body: JSON.stringify({
title: "Forged",
actor: "test",
filters: { injectedFilter: "abc" },
}),
});
expect(invalidCapture.status).toBe(400);
});
});
Loading
Loading