diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 9400d74..6babde2 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { createHash } from 'node:crypto'; import { AgentProviderError, BrowserTestAgentProvider, @@ -1143,6 +1144,99 @@ describe('game API simulation boundary', () => { ).toMatchObject({ inserted: 3, idempotent: false }); }); + it('regenerates and verifies an exact artifact from a compact archive request', async () => { + const archiveExperimentExport = vi.fn( + (document: ExperimentExportDocument) => ({ + experimentId: document.experiment.id, + inserted: 3, + existing: 0, + skipped: 0, + rejected: 0, + idempotent: false, + }), + ); + const app = createApp({ archiveExperimentExport }); + const request = { + agents: { mode: 'all' as const }, + turns: { mode: 'entire-retained' as const }, + outcomes: ['accepted' as const], + actions: ['wait' as const], + level: 'full-safe' as const, + }; + const generatedResponse = await app.request( + '/api/simulation/experiment/export', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }, + ); + const generated = experimentExportResponseSchema.parse( + await generatedResponse.json(), + ); + const response = await app.request( + '/api/simulation/experiment/export/archive', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + request, + generatedAt: generated.document.generatedAt, + sha256: createHash('sha256') + .update(JSON.stringify(generated.document)) + .digest('hex'), + }), + }, + ); + expect(response.status).toBe(200); + expect(archiveExperimentExport).toHaveBeenCalledWith(generated.document); + }); + + it('rejects a compact archive request when the generated artifact changed', async () => { + const archiveExperimentExport = vi.fn(); + const app = createApp({ archiveExperimentExport }); + const exportRequest = { + agents: { mode: 'all' as const }, + turns: { mode: 'entire-retained' as const }, + outcomes: ['accepted' as const], + actions: ['wait' as const], + level: 'full-safe' as const, + }; + const generatedResponse = await app.request( + '/api/simulation/experiment/export', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(exportRequest), + }, + ); + expect(generatedResponse.status).toBe(200); + const generated = experimentExportResponseSchema.parse( + await generatedResponse.json(), + ); + const response = await app.request( + '/api/simulation/experiment/export/archive', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + request: generated.document.filters, + generatedAt: generated.document.generatedAt, + sha256: '0'.repeat(64), + }), + }, + ); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: { + code: 'artifact_changed', + message: + 'The experiment changed after this export was generated. Generate it again before saving.', + }, + }); + expect(archiveExperimentExport).not.toHaveBeenCalled(); + }); + it('rejects invalid archive artifacts before invoking persistence', async () => { const archiveExperimentExport = vi.fn(); const app = createApp({ archiveExperimentExport }); diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index 8e947d0..a568c06 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -1,5 +1,6 @@ import { Hono, type Context } from 'hono'; import { cors } from 'hono/cors'; +import { createHash } from 'node:crypto'; import { BrowserTestAgentProvider, AgentProviderError, @@ -865,9 +866,31 @@ export function createApp(options: AppOptions = {}) { 400, ); try { + const document = + 'document' in request.data + ? request.data.document + : service.generateExperimentExport( + request.data.request, + request.data.generatedAt, + ); + if ( + 'sha256' in request.data && + createHash('sha256').update(JSON.stringify(document)).digest('hex') !== + request.data.sha256 + ) + return context.json( + apiErrorSchema.parse({ + error: { + code: 'artifact_changed', + message: + 'The experiment changed after this export was generated. Generate it again before saving.', + }, + }), + 409, + ); return context.json( archiveExperimentExportResponseSchema.parse( - await archiveExperimentExport(request.data.document), + await archiveExperimentExport(document), ), ); } catch (error) { diff --git a/apps/game-api/src/simulation-service.ts b/apps/game-api/src/simulation-service.ts index 1bb99b2..b7dc78f 100644 --- a/apps/game-api/src/simulation-service.ts +++ b/apps/game-api/src/simulation-service.ts @@ -845,13 +845,16 @@ export class SimulationService { ); } - generateExperimentExport(request: unknown): ExperimentExportDocument { + generateExperimentExport( + request: unknown, + generatedAt = this.#now(), + ): ExperimentExportDocument { if (this.#busy || this.#verificationBusy) throw new SimulationConflictError( 'Export is unavailable while model execution is in progress.', ); return experimentExportDocumentSchema.parse( - createExperimentExport(this.#experimentSource(), request, this.#now()), + createExperimentExport(this.#experimentSource(), request, generatedAt), ); } diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index a167ee9..70cf213 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1023,6 +1023,15 @@ async function openOverflow(user: ReturnType) { if (!menu.closest('details')?.hasAttribute('open')) await user.click(menu); } +async function selectMinimalFixtureExport( + user: ReturnType, +) { + await user.click(screen.getByRole('button', { name: 'Clear' })); + await user.click(screen.getByRole('checkbox', { name: /Ember/ })); + await user.click(screen.getByRole('checkbox', { name: 'lost tick' })); + await user.click(screen.getByRole('checkbox', { name: 'operator skipped' })); +} + async function openAgentsWorkspace(user: ReturnType) { await user.click(await screen.findByRole('button', { name: 'Agents' })); } @@ -3216,6 +3225,7 @@ describe('WorldLab', () => { render(); await openOverflow(user); await user.click(screen.getByRole('button', { name: 'Export' })); + await selectMinimalFixtureExport(user); expect(screen.getByRole('button', { name: 'Copy JSON' })).toBeDisabled(); expect( screen.getByRole('button', { name: 'Download JSON' }), @@ -3254,7 +3264,9 @@ describe('WorldLab', () => { const requestsBeforeArchive = fetchMock.mock.calls.length; fireEvent.click(archiveButton); fireEvent.click(archiveButton); - expect(fetchMock.mock.calls).toHaveLength(requestsBeforeArchive + 1); + await waitFor(() => + expect(fetchMock.mock.calls).toHaveLength(requestsBeforeArchive + 1), + ); expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled(); resolveArchive( await jsonResponse({ @@ -3266,13 +3278,24 @@ describe('WorldLab', () => { idempotent: false, }), ); - expect(fetchMock.mock.calls.at(-1)).toEqual([ + const [archiveUrl, archiveInit] = fetchMock.mock.calls.at(-1)!; + expect(archiveUrl).toEqual( expect.stringMatching(/\/experiment\/export\/archive$/), + ); + expect(archiveInit).toEqual( expect.objectContaining({ method: 'POST', - body: JSON.stringify({ document: validatedDocument }), + signal: expect.any(AbortSignal), }), - ]); + ); + const archiveBody = JSON.parse(String(archiveInit?.body)); + expect(archiveBody).toMatchObject({ + request: validatedDocument.filters, + generatedAt: validatedDocument.generatedAt, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(archiveBody).not.toHaveProperty('document'); + expect(String(archiveInit?.body).length).toBeLessThan(10_000); expect(await screen.findByText(/saved to SQLite/)).toBeInTheDocument(); fetchMock.mockRejectedValueOnce(new Error('archive unavailable')); await user.click(screen.getByRole('button', { name: 'Save to SQLite' })); @@ -3302,6 +3325,102 @@ describe('WorldLab', () => { click.mockRestore(); }); + it('invalidates a generated artifact when compact SQLite archival reports it changed', async () => { + const user = userEvent.setup(); + const progressed = afterInfection(); + const fetchMock = vi.fn(async () => jsonResponse(progressed)); + vi.stubGlobal('fetch', fetchMock); + render(); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'Export' })); + await selectMinimalFixtureExport(user); + const document = minimalExportDocument(progressed); + fetchMock.mockImplementationOnce(() => jsonResponse({ document })); + await user.click(screen.getByRole('button', { name: 'Generate export' })); + expect( + await screen.findByRole('button', { name: 'Save to SQLite' }), + ).toBeEnabled(); + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { + code: 'artifact_changed', + message: + 'The experiment changed after this export was generated. Generate it again before saving.', + }, + }), + { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }, + ), + ); + await user.click(screen.getByRole('button', { name: 'Save to SQLite' })); + expect( + await screen.findByText( + 'The experiment changed after this export was generated. Generate it again before saving.', + ), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy JSON' })).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Download JSON' }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Save to SQLite' }), + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Generate export' }), + ).toBeEnabled(); + expect(screen.queryByText(/saved to SQLite/)).not.toBeInTheDocument(); + }); + + it('aborts a stalled compact SQLite archive request after ten seconds', async () => { + const user = userEvent.setup(); + const progressed = afterInfection(); + const fetchMock = vi.fn(async () => jsonResponse(progressed)); + vi.stubGlobal('fetch', fetchMock); + render(); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'Export' })); + await selectMinimalFixtureExport(user); + const document = minimalExportDocument(progressed); + fetchMock.mockImplementationOnce(() => jsonResponse({ document })); + await user.click(screen.getByRole('button', { name: 'Generate export' })); + + let archiveStarted!: () => void; + const started = new Promise((resolve) => { + archiveStarted = resolve; + }); + fetchMock.mockImplementationOnce( + (_input, init) => + new Promise((_resolve, reject) => { + archiveStarted(); + init?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }), + ); + vi.useFakeTimers(); + try { + fireEvent.click(screen.getByRole('button', { name: 'Save to SQLite' })); + await started; + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect( + screen.getByText(/Could not confirm the SQLite save/), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Save to SQLite' }), + ).toBeEnabled(); + expect(screen.queryByText(/saved to SQLite/)).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + it('auto-pauses a fully infected world and disables automatic Start only', async () => { const infected = simulationSnapshotSchema.parse({ ...initial, diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 11e8b8d..3c6f5db 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -4796,6 +4796,7 @@ function ExperimentExportPanel({ if ( !document || !documentIsCurrent || + !parsedRequest.success || operation !== null || sqlitePendingRef.current ) @@ -4803,12 +4804,28 @@ function ExperimentExportPanel({ sqlitePendingRef.current = true; setOperation('sqlite'); setNotice(null); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 10_000); try { + const sha256 = await sha256Hex(JSON.stringify(document)); const response = await fetch(`${apiBase}/experiment/export/archive`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ document }), + body: JSON.stringify({ + request: parsedRequest.data, + generatedAt: document.generatedAt, + sha256, + }), + signal: controller.signal, }); + if (response.status === 409) { + setDocument(null); + setGeneratedRequestJson(null); + setNotice( + 'The experiment changed after this export was generated. Generate it again before saving.', + ); + return; + } if (!response.ok) throw new Error('archive request failed'); const result = archiveExperimentExportResponseSchema.parse( await response.json(), @@ -4823,6 +4840,7 @@ function ExperimentExportPanel({ 'Could not confirm the SQLite save. Retry safely with the same generated export.', ); } finally { + window.clearTimeout(timeout); sqlitePendingRef.current = false; setOperation(null); } @@ -5248,6 +5266,16 @@ function serializeExportDocument(document: ExperimentExportDocument): string { : JSON.stringify(document); } +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(value), + ); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); +} + function EventLog({ snapshot, turns, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f37e318..8784203 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -194,8 +194,11 @@ Snapshots keep the newest 120 turn records and 120 world events. Observations ex ## Experiment telemetry and export World Lab archive writes remain downstream and manual. The browser submits the -exact current schema-validated generated document; the Game API lazily opens the -configured archive only for that request, delegates the transactional, +current export filters, generation timestamp, and SHA-256 digest rather than +re-uploading a potentially large document through the UI proxy. The Game API +deterministically regenerates the schema-validated document, rejects it if its +digest differs from the exact browser-generated artifact, lazily opens the +configured archive only after that check, delegates the transactional, idempotent import to `packages/experiment-archive`, and closes the handle. The archive never becomes simulation authority. diff --git a/docs/TESTING.md b/docs/TESTING.md index 3382b48..9352178 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -91,11 +91,11 @@ and approximately 768×900 with deterministic scripted data only. Command-navbar coverage verifies one persistent row, always-visible known cost, stable async control slots, accessible icon controls, exact absolute tick targets 5/10/25/50/100, current/past target advancement, hover/focus experiment details, responsive overflow, and removal of the provider badge and inspector export action. Map interaction coverage keeps agent-marker inspection independent from explicit hex selection and verifies that the map-local hex card dismisses on a background click. Export coverage separates preview from generation, includes lost ticks by default, invalidates artifacts after relevant option changes, prevents stale or duplicate copy/download, and checks accessible pending/ready/error states. Shared model-option tests require identical global/per-agent ordering and formatting. Effective-color tests cover current-alliance, retained, base, and neutral fallback precedence. Dark-map tests assert tokenless CARTO URLs and complete attribution without network access. Direct archive-write coverage validates the narrow request/response schemas, -exact generated-document submission, pre-generation and stale-artifact -disabling, synchronous duplicate-activation prevention, bounded notices, -injected persistence without startup file creation, and idempotency through the -existing importer. Tests use injected writers, in-memory SQLite, or temporary -paths only. +compact digest-verified regeneration of the exact generated document, mismatch +rejection, pre-generation and stale-artifact disabling, synchronous +duplicate-activation prevention, bounded notices, injected persistence without +startup file creation, and idempotency through the existing importer. Tests use +injected writers, in-memory SQLite, or temporary paths only. Behavior coverage verifies registry uniqueness/versioning, deterministic balanced and fully random assignment, independent profile dimensions, turn-one locking, reset semantics, exact diplomacy affordances, layered prompt trust language, bounded structural detail codes, turn attribution, and export preservation. Agent Controller coverage verifies accessible Overview/Models/Behavior tabs, default readiness, manual pre-turn selection, post-start locking, responsive dialog layout, and compact roster summaries. diff --git a/docs/adr/0021-manual-direct-sqlite-export.md b/docs/adr/0021-manual-direct-sqlite-export.md index 9295a87..b107bf8 100644 --- a/docs/adr/0021-manual-direct-sqlite-export.md +++ b/docs/adr/0021-manual-direct-sqlite-export.md @@ -6,13 +6,18 @@ ## Decision World Lab offers **Save to SQLite** only after the operator explicitly generates -an export. Preview remains optional. Saving submits that exact schema-validated -document, and changed options make the artifact stale until regenerated. +an export. Preview remains optional. Saving submits a compact request containing +the export filters, generation timestamp, and SHA-256 digest. The Game API +deterministically regenerates the schema-validated document and archives it only +when its digest matches the exact browser-generated artifact. Changed options or +experiment state make the artifact stale until regenerated. The Game API runtime-validates one narrow request, lazily opens the configured archive, delegates to `ArchiveDatabase` and `importExperimentExport`, and closes the handle. Its bounded response contains the experiment ID, import counts, and idempotency indicator. The browser cannot choose a path or provide SQL. +The compact request stays below the framework proxy's bounded request-body +limit even when the generated Full Safe artifact is large. ## Consequences diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index cb2dcb6..99ac0f0 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1305,6 +1305,14 @@ describe('personality mutation contracts', () => { }, }).success, ).toBe(true); + expect( + apiErrorSchema.safeParse({ + error: { + code: 'artifact_changed', + message: 'Generate the export again before saving.', + }, + }).success, + ).toBe(true); expect( apiErrorSchema.safeParse({ error: { code: 'provider_secret', message: 'unsafe' }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 431a90c..704146e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2767,6 +2767,7 @@ export const apiErrorCodeSchema = z.enum([ 'invalid_request', 'invalid_export', 'invalid_artifact', + 'artifact_changed', 'archive_rejected', 'archive_persistence_failed', 'export_conflict', @@ -3758,9 +3759,20 @@ export type ExperimentExportDocument = z.infer< export const experimentExportResponseSchema = z.object({ document: experimentExportDocumentSchema, }); -export const archiveExperimentExportRequestSchema = z +const generatedExperimentExportArchiveRequestSchema = z .object({ document: experimentExportDocumentSchema }) .strict(); +const compactExperimentExportArchiveRequestSchema = z + .object({ + request: experimentExportRequestSchema, + generatedAt: z.iso.datetime(), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + }) + .strict(); +export const archiveExperimentExportRequestSchema = z.union([ + generatedExperimentExportArchiveRequestSchema, + compactExperimentExportArchiveRequestSchema, +]); export const archiveExperimentExportResponseSchema = z .object({ experimentId: experimentIdSchema,