Skip to content

feat(verify): durable verification reports that outlive Temporal retention, with linked reconstruction and re-verification - #163

Open
NovrusShehaj wants to merge 23 commits into
fix/simulators-error-fixfrom
fix/archive-verification-results
Open

NovrusShehaj wants to merge 23 commits into
fix/simulators-error-fixfrom
fix/archive-verification-results

Conversation

@NovrusShehaj

@NovrusShehaj NovrusShehaj commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

This implements the Verification Report Retention plan, "Durable Reports and Linked Reconstruction": Phase A (durable original reports) in c9e9daa and Phase B (linked recovery) in e26d039, plus the fixes from the second-pass Phase B audit in 0407ef3. def76af makes the reconcile CronJob opt-in and gives it its own scratch volume (see "Deployment safety" below). The fixes from the review of this PR follow in f6078ac…76b155c (see "Review remediation" below). Start with the reviewer guide.

Stacked on #162. The base branch is fix/simulators-error-fix, so this diff shows only the retention commits. The repo deletes merged branches, so GitHub will retarget this PR to main when #162 merges.

Reviewer guide

The PR stays whole (the owner decided not to split it), so it is laid out to be reviewed in parts. Phase A (durable reports) can be signed off before Phase B (linked recovery).

Reading order. Each step names the invariants it must uphold:

  • I-1 a computed result is never degraded;
  • I-2 every writer converges on the same stored content;
  • I-3 payloads are bounded;
  • I-4 no storage work before admission;
  • I-5 the job's exit code is job health;
  • I-6 storage is O(distinct content);
  • I-7 CI fails when Temporal is broken.
# Area Files Invariants
1 Storage contract and codec biosim_verify/report_codec.py, report_storage.py, object_names.py; common/storage/file_service*.py, gcs_aio.py, content_addressed.py I-2, I-6
2 Persistence activity and workflows biosim_verify/activities.py (persist_verification_report_activity, prepare_report, externalize_statistics), workflow_persistence.py, history.py, omex_verify_workflow.py, runs_verify_workflow.py I-1, I-2, I-3
3 The GET lookup path api/main.py: get_verify_output, _recover_verification_from_history, _publish_recovered_output, _ledger_fallback_404, _outage_view I-1, I-2 (and D6, the outage answers)
4 Reconciler, backfill, backup, acknowledgement, GC biosim_verify/report_recovery.py, report_backup.py, database.py; scripts/reconcile_verification_reports.py, ack_verification_persistence.py, gc_verification_objects.py, backfill_verification_reports.py, backup_verification_reports.py I-5, I-6, I-2
5 Compose, smoke, kustomize, ops compose.yaml, scripts/smoke-infrastructure.sh, .github/workflows/smoke.yaml, kustomize/components/verification-reconcile/, ops/ I-7
6 Phase B: linked recovery biosim_verify/linked_recovery.py, reconstruct_workflow.py, snapshots.py, comparison.py; the recovery routes in api/main.py I-4
7 Frontend components/verification/VerificationStatus.vue, utils/verification-status.ts, utils/verification-recovery.ts, models/verification.ts, pages/utilities/verify-model.vue —

The docs follow the code: docs/verification-report-retention.md (runbook), docs/workflows-architecture.md, backend/CLAUDE.md.

Phase A and Phase B files. Phase A is reading-order steps 1–5, plus the status/availability part of step 7 (verification-status.ts and the "Saving report" and "Original report unavailable" states). Phase B is step 6 and the rest of step 7: recovery actions, derived links and catalog badges. Four files hold both:

  • models.py: Phase B adds the derivation, recovery and link models;
  • database.py: Phase B adds the idempotency reservations, list_derived and the caps;
  • activities.py: Phase B adds snapshot capture and link_derived;
  • api/main.py: Phase B adds the /recovery, /reconstruct and /reverify routes and the annotation.

Review fix commits, by finding. B = blocker, S = smaller issue, X = found during remediation.

Commit Findings
f6078ac Fix local Temporal volume ownership B1, B2 (compose), X-2
6f2f7ab Make smoke fail when Temporal is unavailable B2 (smoke), X-4
e9e46db Use persistence-state enums and classify zlib errors as corrupt S-4, S-5
263b1f9 Move history helpers out of the API and make terminal failures deterministic B3, B4 (code), S-3, S-7 (history.py), X-1
22e60f0 Retry report persistence until it succeeds or is blocked B3 (replay fixture only; see "Reading the Phase 2 commits")
a304e21 Keep statistics out of workflow results and queries B4 (tests only)
ec785d4 Test payload compatibility with the 0.10.3 data converter S-6
7f2c032 Separate reconciler job health from data attention B6, X-3
363609d Add persistence acknowledgement B6
a9af171 Add create-only, content-addressed object publication B7
72f4ea3 Remove transient verification objects and bound snapshot capture B7
ed919e2 Add verification object garbage collection B7
4528f82 Admit reconstruction before any storage work B5
510d376 Serve recovery offers on demand S-1
8c9cde3 Bound and rank derived verification links S-2
509f6ee Show recovery actions only where they apply S-1, S-2 (frontend)
c740715 Give backup public persistence APIs and type derivation kinds S-7
0a5981c Keep progress polling available during a Mongo outage S-8
76b155c Document the verification retention review remediation docs for all of the above

Where the tests are

Invariant Tests that pin it Mutations those tests catch
I-1 test_persistence_failures (25 transient failures then stored, permanent → blocked, terminated while persisting, unreadable result never replaced) 20-attempt cap; history scan removed; failed execution's query view trusted
I-2 test_persistence_failures (terminated while computing: identical digests; losing writer gets 200; stored-but-missing is 503), test_history different GET failure message; prepare_report skipped; fresh provenance on recovery
I-3 test_workflow_payloads, test_generate_statistics persist result keeps statistics
I-4 test_recovery_admission, test_recovery_offers, test_derived_links charge after the existence check; cap after the charge; annotating a running poll
I-5 test_reconcile_job, test_persistence_ack, test_verification_reconcile_component exit nonzero on a blocked row; acknowledgement ignoring the error or expected state
I-6 test_object_storage, test_transient_objects, test_object_gc, test_file_service_create_only, test_gcs_missing_objects UUID-suffixed names; no transient cleanup; GC ignoring references, live pointers or the grace period
I-7 test_compose_drift, test_smoke_infrastructure, the smoke workflow init depends_on removed; the old def76af compose (see "Smoke failure evidence")
D6 outage test_verification_outage (16 cases, each asserting no writes) outage back to a plain 503; NOT_FOUND → 404; stored result served from Temporal; outage view before authorization
S-6, S-7 test_converter_compat, test_layering backup reading a private collection

The full mutation record, per phase, is under "Tests". The six mutations the remediation plan's validation matrix requires were re-run on the final SHA (see "Final validation").

Disclosures

  • Statistics do cross Temporal, up to 512 KiB. An earlier version of this description said statistics never travel through Temporal payloads. That was wrong. Statistics of at most 512 KiB of uncompressed JSON travel inline in the statistics activity's result and the persist activity's input, so they are in workflow history until the report is stored. Larger ones are externalized, and only a reference crosses. After persistence, the persist activity's result, the workflow result and every query carry no statistics.
  • Data converter change. The API and worker now connect with pydantic_data_converter; 0.10.3 used the SDK's default DataConverter. test_converter_compat.py proves it compatible with every payload type 0.10.3 could write, and reads and replays a history written by the old converter, so backfill can read retained 0.10.3 histories. No decoding shim is needed.
  • Mongo availability. A saved report is served without Temporal, but it needs Mongo. During a Mongo outage, GET /verify/{id} still serves running progress and never-stored results from Temporal. A stored report, a failed execution and an unknown id are a 503 with Retry-After: 5, never a 404. Stored reports depend on Mongo, and running progress does not.

Problem

Verification reports existed only in Temporal workflow history. The ledger (BiosimCompare) stored a verification's identity, owner, archive hash and terminal status, but not the report or the request that produced it. Once Temporal removed the history, GET /verify/{id} could only answer 404 "Verification <id> exists but its results are no longer retained."

  • Production namespace default has 24h retention, and history and visibility archival are disabled (checked directly on 2026-10-29).
  • In production's anonymous catalog, the three newest public entries already returned that 404.
  • Locally, compose.yaml ran Temporal in-memory, so restarting the container lost every report.
  • The status modal showed PENDING for a missing report and then stopped polling, leaving a futile Retry button.

Recovery must never overwrite a historical answer. There is no same-ID rerun, no report reset, and no silent recomputation on GET.

Phase A: durable original reports (c9e9daa)

Immutable report storage (BiosimVerifyReports)

  • report_codec.py: the verification-json-gzip-v2 codec keeps NaN and ±Inf scores and literal dotted dataset keys. The SHA-256 covers the canonical uncompressed bytes.
  • report_storage.py: statistics above 512 KiB are externalized to object storage before they cross Temporal. After persistence, workflow results and queries carry no statistics. Objects get deterministic, content-addressed names and are only ever created, never overwritten, so a retry reuses the object it already wrote; the report is then published through an insert-once manifest. An identical retry is a no-op; conflicting content, owner or run IDs cannot replace a published report.
  • New size limits in Settings: 12 MiB inline report, 64 MiB decompressed ceiling (corrupt or oversized payloads are rejected), and 512 KiB for an activity's inline payload (measured on the uncompressed JSON).

Publication before completion

  • persist_verification_report_activity stores the report before the ledger pointer is updated or completion is acknowledged.
  • OmexVerifyWorkflow and RunsVerifyWorkflow gate the new commands behind workflow.patched("durable-verification-report-v1"). Runs statistics also sit behind durable-verification-statistics-v1 (generate_statistics_activity_v2). In-flight and pre-change histories therefore replay, and replay is tested.
  • All three verification workflows persist through one call site and one retry policy (workflow_persistence.py).
    • Transient failures are retried with no attempt limit: 1s initial interval, 5 min maximum. A storage outage delays a report, but it never turns a computed result into a failure, and it is never reported as durable completion.
    • A permanent failure marks the row blocked. Permanent failures are a ValueError, an integrity conflict, a corrupt artifact or a missing artifact.
    • On a permanent failure, the workflow completes with the computed output marked persistence_state=blocked. A successful verification is never reported as FAILED.
  • A failed, terminated, cancelled or timed-out execution is recovered from its history.
    • If it computed an output, that output is stored.
    • If the history proves nothing was computed, one deterministic, sanitized FAILED report is built (history.terminal_failure_output), with honest missing-result provenance.
    • GET and the reconciler use the same code for both, so they write identical content.

Lookup: GET /verify/{id} reads the saved report first

  • A saved report is served without Temporal, workers, or biosimulations.org.
  • Without a saved report, the endpoint describes the Temporal execution and pins its run ID.
    • A completed execution is read with WorkflowHandle.result(), with no worker query, then authorized and persisted as a catch-up write. If result() can't be read, GET returns 503 and stores nothing.
    • A failed, terminated, cancelled or timed-out execution is recovered from its history, as described above. GET never builds its report from the query view.
    • If the catch-up write fails, the result is still returned, marked persistence_state=pending.
    • If another writer already stored a different report, GET serves the stored one (200). If that report can't be re-read, GET returns 503 and logs verification_persistence_attention. It never returns a 500.
    • A result marked stored whose report is missing is a 503 with an attention log. The slim Temporal result is never served as the report.
    • A running execution still answers progress queries. GET never starts simulations or comparisons.
  • A corrupted artifact is a 503, not a 404. A missing report is reported separately, and hidden rows still get the generic 404.
  • During a Mongo outage, GET answers what Temporal alone can.
    • A running verification still returns its progress, authorized from the workflow's own owner and sent private, no-store.
    • A completed result that was never stored is returned marked pending.
    • A stored report, a failed execution, and an unknown ID are a 503 with Retry-After: 5, never a 404.
    • Nothing is written until Mongo is back.
    • The trade-off: stored reports depend on Mongo, and running progress does not.
  • The expired-row 404 keeps its exact detail string for older clients.
  • The API now starts without Temporal. It retries the connection every 5s in the background. Readiness reports Temporal as degraded but keeps routing saved-report reads. A new TEMPORAL_NAMESPACE setting defaults to default.

Operations

  • scripts.reconcile_verification_reports repairs missed persistence and terminal failures without needing a user to call GET. Its exit status reflects the job's health only; rows that need an operator raise an alert, and scripts.ack_verification_persistence acknowledges them without touching a report (see "Review remediation").
    • A new verification-report-reconcile CronJob runs it every 5 minutes: Forbid concurrency, 240s deadline, 150s scan budget, 45s per-row budget. It ships as the opt-in kustomize component kustomize/components/verification-reconcile, which no overlay enables yet.
    • A Mongo lease and cursor in BiosimVerifyMaintenance fence concurrent jobs, and a job killed at its deadline resumes where it stopped.
  • scripts.backfill_verification_reports is resumable and dry-run by default, with 0600 checkpoints, bounded pages, and --since/--until. It never starts workflows or simulations, and it never invents ownership. Rows with unknown owners are reported for investigation.
  • scripts.backup_verification_reports export|restore exports ledger/request BSON, report envelopes, referenced objects, and a checksum manifest. Restore checks every checksum and report identity before writing (--apply to write). Backup and restore go through the report store's and the ledger's public methods only.
  • ops/monitoring/ holds alert templates (not installed):
    • a log alert for verification_persistence_attention, verification_persistence_failure and verification_object_integrity_conflict;
    • a reconcile-run metric that counts the verification_reconcile_summary heartbeat;
    • a 30-minute absence policy.
  • ops/storage/verification-lifecycle.json is a GCS lifecycle rule for the two transient prefixes (not applied automatically), and scripts.gc_verification_objects collects unreferenced long-lived objects, dry-run first.
  • The runbook is in docs/verification-report-retention.md: drain, backfill, Temporal recreation, backup and restore drill, and alert response.

Local Temporal persistence: compose.yaml now runs start-dev --db-filename /data/temporal.db on a new temporal_data volume. A one-shot temporal-data-init service hands the volume to the image's uid/gid 1000 (f6078ac); without it the server could not open its SQLite file. Retention still applies; this only makes local history survive a restart or recreation.

Frontend: verification-status.ts and VerificationStatus.vue separate workflow status from report availability, showing "Saving report", "Original report unavailable", or a service error. Polling stops for lookups that can't recover. The component guards against late responses after the workflow ID changes and cancels on unmount.

Phase B: linked recovery (e26d039, 0407ef3)

Both routes require an Idempotency-Key header and return 202 with a new ID. The source's ledger row, report bytes, content hash, status and permalink stay unchanged.

Route Workflow Starts simulator jobs?
POST /verify/{id}/reconstruct ReconstructComparisonWorkflow (new ID prefix reconstruction-) No. It compares retained snapshots only.
POST /verify/{id}/reverify OmexVerifyWorkflow (new ID prefix reverification-) Yes. This is a new verification of the stored archive.

Comparison-only reconstruction

  • Numerical-input snapshots are captured inside the existing statistics activity (snapshots.py). They are content-addressed by SHA-256 and published through a per-workflow pointer. A failed capture logs verification_snapshot_capture_failed and leaves the original report intact; it only makes that report ineligible for reconstruction.
  • ReconstructComparisonWorkflow runs compare_retained_snapshots_activity and then the shared persist activity. Its module never references submit_biosim_run_activity, get_hdf5_data or get_existing_biosim_simulation_run_activity, so it works while biosimulations.org is down. A missing or mismatched snapshot raises a non-retryable ApplicationError.
  • Eligibility comes from the persisted provenance manifest, never from the saved request alone. Refusals are typed 409s that reserve no ID and start nothing: missing_provenance, missing_numerical_inputs, input_mismatch (the client must echo the stored run_ids, snapshot_sha256 and compare_settings), and work_limit. Admission only checks that the retained inputs exist. A checksum mismatch is caught by the comparison, and the derived verification then fails with failure_code: checksum_mismatch.
  • The derived report records the comparator version, computed_at, and its source. It is labelled a new calculation, not a restoration.

Archive re-verification: this needs a stored, readable archive. The offer shows the documented defaults (simulators and tolerances) and says plainly that it can start new simulations. Rows that only have runs, with no stored archive, are refused.

Shared submission contract

  • Idempotency is scoped to caller, source, operation and request hash, under a unique index on BiosimVerifyIdempotency.
    • Concurrent identical requests converge on one ID.
    • A different body under the same key returns 409 idempotency_conflict.
    • When a start reply is lost, the server confirms an ambiguous start against the workflow's history start event. If it can't be confirmed, the request returns 503, and a retry reuses the reserved ID.
  • The shared workflow rate limit is charged only when a new reservation is created, and before any object storage is read. One caller may start at most 10 derived verifications of one source per day, or 3 anonymously; beyond that the request is a 429 with code derived_limit, and it is not charged. Retries cost nothing. Existing input bounds apply, for example more than 10 simulators, or a negative tolerance, is 422.
  • Derived rows inherit the source's owner_sub, so a private source never produces a public result. They also store source_verification_id and derivation_kind, and record the caller as initiator_sub for audit and quota. initiator_sub is omitted from listings and public output. Hidden sources return a plain 404.
  • The original permalink keeps showing the original report, or that it is unavailable, and links to authorized derived verifications. It never redirects.
    • Links are ranked, newest first in each group: owner- and admin-started ones, then the caller's own (each capped at 10), then the 5 newest others. derived_verifications_total gives the full count.
    • A link names who started it only as initiator_relation, plus is_yours, never as an identity, and flags custom_settings.
    • On the "no longer retained" 404, recovery and derived_verifications are sibling fields of detail, sent with Cache-Control: private, no-store. A caller who can't see the row gets neither field.
    • A finished report's offers come from GET /verify/{id}/recovery, never from the report response.

Frontend

  • The modal offers "Reconstruct comparison" and "Start re-verification" as separate actions and states the limits of each. They appear unprompted only for an unavailable original. A finished report shows them behind a collapsed "Recompute…", and nothing shows while a verification is running or saving. The re-verify form edits the simulators, tolerances, outputs and description, with labelled defaults and the server's bounds.
  • Derived reports show their source link, derivation badge, computation time and comparator provenance. The original's page lists its derived verifications, grouped by who started them and with "N more" and "Custom tolerances" badges, with the note "They do not replace this result."
  • Idempotency keys come from crypto.getRandomValues and are unique per request body.
  • The catalog (verify-model.vue) shows a "New calculation" or "Re-verification" badge with the source ID.

Fixes from the second-pass audit (0407ef3)

  • GCS missing objects. gcloud-aio-storage reports a missing object as an HTTP 404 ClientResponseError, never as FileNotFoundError. gcs_aio.py now maps that status, and only that status, onto the FileService contract: None from get_file_contents, and FileNotFoundError from get_modified_date. Before this fix, on GCS:

    • a failed verification that never wrote a snapshot pointer could not persist its report;
    • a missing snapshot became an outage instead of a typed refusal.

    Other HTTP errors are still raised.

  • The unavailable-original 404 now carries derived_verifications, and the UI renders them.

  • The client's idempotency key is now random and unique per request body.

The OpenAPI spec is regenerated, and frontend/app/models/verification.ts is updated with the new additive optional fields. Readers tolerate old rows that have no inputs, provenance, or stored report.

Review remediation (f6078ac…76b155c)

A review of this PR found these problems:

  • CI did not really exercise Temporal.
  • Under failure, a computed result could be degraded, or two writers could store different reports for the same verification.
  • One bad row failed the reconcile job on every run.
  • Object storage grew with every retry, without bound.
  • Recovery did its storage work before the rate limit, offers were built on every progress poll, and anyone could flood a public verification's derived links.
  • A Mongo blip made every GET a 503, and backup reached into private persistence state.

Phases 1–5 of the remediation fix them, and Phase 6 documents them.

Phase 1: CI honest and green

  • f6078ac: fixes local Temporal volume ownership (see "Local Temporal persistence" above). test_compose_drift.py pins the image, the init step and the health checks without Docker.
  • 6f2f7ab: smoke now fails when Temporal is unavailable.
    • It waits for SERVING.
    • It proves a workflow survives a container recreation.
    • It requires checks.temporal == true from the API.
    • Run against the old compose.yaml, it exits 1. Evidence is in docs/pr-163-phase-1-validation.md.
  • e9e46db: persistence code uses ReportPersistenceState members, so the serializer no longer warns. A corrupt DEFLATE stream (zlib.error) is now classified ReportCorrupt.

Phase 2: a computed result is never degraded, writers converge, payloads are bounded

  • History helpers live in biosim_verify/history.py, so the reconcile CronJob no longer imports the FastAPI app.
  • Recovery from history. The computed output is the input of the last persist activity, checked against the start event's workflow ID, run ID and owner. History is read in 200-event pages, with a 15s RPC timeout and a 20,000-event cap.
  • Converging writers. The persist activity, GET and the reconciler all shape a report with activities.prepare_report(). Recovered reports keep their snapshots, run order and re-verification link, and their recorded provenance is marked recovered_from_history.
  • Retry and failure classes. Persistence retries have no attempt limit, and a permanent failure gives blocked, never FAILED (see "Publication before completion").
    • A missing artifact (ReportArtifactMissing) is permanent. Unconfigured or unreachable storage stays retryable.
    • A ledger read failure is retried instead of swallowed, so a report is never stored without its re-verification link.
    • A stored ledger row is never marked unavailable afterwards.
  • Slim payloads. After persistence, the persist activity result, the workflow result and the query carry no statistics. Statistics up to 512 KiB travel inline in the statistics activity result; larger ones are externalized.
  • Converter compatibility (see "Before deploying"). Every payload type 0.10.3 could write decodes identically under the new converter, including NaN, ±Inf, enums and None. A history written by the old default converter is also read and replayed under the new one. No decoding shim is needed.
  • Replay. Histories recorded under the previous 20-attempt persistence policy replay under the new workflow code, both in flight and completed.

Reading the Phase 2 commits one at a time: all Phase 2 production code is in 263b1f9. 22e60f0, a304e21 and ec785d4 add only tests and a fixture, although the messages of 22e60f0 and a304e21 describe the persistence and inline-statistics code that is actually in 263b1f9.

  • 263b1f9 and 22e60f0 do not pass on their own, because a fixture and two test updates land in later commits.
  • Bisect from a304e21.
  • The history is kept as is, with no force-push.

Phase 3: the reconcile job reports its own health; storage grows with distinct content, not retries. Each Phase 3 commit passes the full backend suite on its own.

  • 7f2c032 Job health, not row health.
    • A scan that ran exits 0 and always logs verification_reconcile_summary, the heartbeat. It adds verification_persistence_attention only when a row needs an operator.
    • A busy lease exits 0 with skipped="lease_busy".
    • Initialization failure, a lost lease or an unexpected exception exits 1 with verification_reconcile_job_failed. That event carries the reason and the exception type, never the message.
    • Staleness comes from the execution's pending activities. A persist activity at attempt 3 or later has been retrying for at least the time its retry intervals add up to, and it alerts after 1h. Anything else running alerts after 24h.
    • The CronJob uses restartPolicy: Never and backoffLimit: 0, so the next 5-minute run is the retry.
  • 363609d Acknowledgement.
    • persistence_ack = {state, error, reason, acknowledged_by, acknowledged_at} on the ledger row. It applies only while the row's state and error are unchanged, so a different failure alerts again.
    • scripts.ack_verification_persistence is dry-run by default. It refuses stored rows, rows not in --expect-state, and rows that change before the write.
    • The reconciler skips acknowledged blocked rows; --include-acknowledged retries them after a fix release.
  • a9af171 Create-only, content-addressed objects.
    • FileService gains put_if_absent, exists and delete_file:
      • GCS uses ifGenerationMatch=0 and treats a 412 as "exists";
      • local storage writes a temporary file and links it into place;
      • MinIO uses If-None-Match: *, with a check-then-put fallback.
    • publish_content_addressed creates or reuses an object. An existing object at that name whose content doesn't match is never overwritten: the bytes go to a .repair-<uuid> copy, and the run logs verification_object_integrity_conflict.
    • Every writer uses deterministic names, and upload_bytes_immutable is gone. Ten persist retries create no new objects, and two verifications of the same run data share one object.
  • 72f4ea3 Transient cleanup and a snapshot budget.
    • Once the report is stored, the persist activity deletes the workflow's statistics artifact and snapshot pointer, best-effort.
    • A retried attempt that finds the report already stored only acknowledges it. Without that check, a worker that died after the cleanup would block the stored report.
    • Snapshots above 64 MiB per verification are not captured, and reconstruction is refused as work_limit.
  • ed919e2 Garbage collection. scripts.gc_verification_objects collects by reachability.
    • The reference set is every object a stored report names, plus everything a live snapshot pointer reaches.
    • Candidates are unreferenced and older than 7 days.
    • --apply deletes at most --max-deletes. It rebuilds the reference set first, and it refuses to delete while a live pointer can't be resolved.
    • Every run writes a 0600 report with per-prefix totals.

Phase 4: recovery is admitted before it costs anything, offered only where it applies, and bounded. Each Phase 4 commit passes the full backend suite on its own.

  • 4528f82 Admission before storage work.
    • Reconstruction runs cheapest first: idempotency peek, in-memory checks, charge, an existence check of each distinct snapshot (8 at a time, 10s), then reserve and start. It no longer downloads anything.
    • A throttled caller is refused before any storage call.
    • A checksum mismatch now fails the derived verification with failure_code: checksum_mismatch instead of a 409.
  • 510d376 Offers on demand.
    • A running poll makes no provenance, archive or storage call.
    • A finished report is annotated from the ledger row the request already read, without a second read.
    • A 200 never carries recovery. The new GET /verify/{id}/recovery serves it, with GET's visibility rules and private, no-store.
  • 8c9cde3 Bounded, ranked derived links.
    • initiator_relation and custom_settings are recorded at reservation.
    • Links are listed by relation, so 105 links from others cannot displace the owner's, an admin's or the caller's own.
    • A per-caller, per-source daily cap is checked before the shared charge.
  • 509f6ee Recovery UI.
    • Actions show unprompted only for an unavailable original, behind "Recompute…" for finished reports, and never while running or saving.
    • The re-verify form is editable, with bounds that mirror ReverifyRequest.
    • Derived links are grouped, with "N more" and "Custom tolerances".
    • The frontend models and the OpenAPI spec are regenerated.

Phase 5: hygiene and availability. Each Phase 5 commit passes the full backend suite on its own.

  • c740715 Public persistence APIs; typed derivation kinds.
    • Backup and restore use only iter_envelopes, read_artifact, check_envelope and restore_envelope on the report store, and iter_rows and restore_row on the ledger.
    • A grep test fails on any access to another object's private persistence state.
    • derivation_kind is a DerivationKind enum everywhere. Old string rows and content hashes are unchanged.
  • 0a5981c Progress during a Mongo outage, as described in "Lookup" above. A 16-case matrix asserts each answer and that nothing is written.

Phase 6: documentation. 76b155c changes docs only.

  • docs/verification-report-retention.md now covers uncapped persistence and blocked, the corrected payload claim, the converter change, the outage answers, the compose init service, reconcile exit semantics and events, the acknowledgement procedure, and the object layout, lifecycle rule and GC procedure.
  • It also states something the old runbook did not: a blocked result is in a completed workflow's history, which retention removes after 24h in production. The fix has to be released and the row retried before then.
  • docs/workflows-architecture.md gains a persistence and history-recovery section and rewrites linked recovery: the admission order, the checksum contract, /recovery and the derived-link policy.
  • backend/CLAUDE.md gains /verify/{id}/recovery, the changed GET and recovery rows, the new modules and scripts, and the FileService additions.

Tests

New suites:

  • test_report_codec, test_report_storage, test_report_recovery, test_retention_operations.
  • test_retention_crash: real SIGKILL of a worker before and after each of upload, manifest commit, ledger update and activity ack.
  • test_retention_integration: old/new replay, backfill pagination, Temporal restart and recreation, reconnect after an outage, history deletion, and an independent restore.
  • test_linked_recovery: 19 tests, including a real worker and real Temporal with upstream patched to fail, plus idempotency convergence and lost replies.
  • tests/common/test_gcs_missing_objects.py.
  • Frontend verification-status and verification-component. The component tests mount the real <script setup> in a vm sandbox.
  • Review remediation:
    • test_compose_drift;
    • test_smoke_infrastructure;
    • test_history (9 tests);
    • test_persistence_failures (21): 25 transient failures then stored, permanent failure to blocked, terminated while persisting or computing (including concurrent writers), the losing writer, stored-but-missing, and replay of the capped-policy history;
    • test_workflow_payloads (4);
    • test_converter_compat (18);
    • Phase 3, 73 new tests:
      • test_reconcile_job (18) and test_verification_reconcile_component (2): exit codes and events, staleness thresholds, and the CronJob and alert filters;
      • test_persistence_ack (13);
      • test_file_service_create_only (9), plus 9 new cases in test_gcs_missing_objects: the 412 mapping, checksums, idempotent deletes, and pageToken paging;
      • test_object_storage (7): retries, shared data, the repair path, and repeated restores;
      • test_transient_objects (6);
      • test_object_gc (9);
    • Phase 4, 43 new tests:
      • backend test_recovery_admission (10): FileService spies on throttled and eligible requests, a charged 409, and a checksum mismatch on a real worker;
      • backend test_recovery_offers (10): no recovery on a 200, the /recovery access matrix, and no work on a running poll;
      • backend test_derived_links (15): ranking, caps, custom settings, and no initiator identity;
      • frontend verification-recovery (4) and 4 new component tests: the visibility matrix, edited settings in the request body, and derived grouping.
    • Phase 5, 21 new tests:
      • test_layering (5): the private-state grep, backup through public methods only, string rows still valid, an unchanged serialized form, and redaction;
      • test_verification_outage (16): running, blocked and pending results, a stored result, the four failed statuses, NOT_FOUND at describe and at query, the owner matrix, a report-store-only outage, and a corrupt report.

Final validation (76b155c)

The final SHA is the current head; 76b155c changes docs only. All five checks are green there:

  • backend-ci runs the full suite (uv run python -m pytest): 1,801 passed, 0 failed, 17 skipped. The skipped tests need external credentials (GCS, SLURM, SSH). The log has zero PydanticSerializationUnexpectedValue.
  • frontend-ci: lint 0 errors (the 2 existing ImageCompare.vue warnings), typecheck, test:verification 41, test:auth 5, test:legacy-proxies 5.
  • smoke logs temporal SERVING, temporal persistence probe survived container recreation and backend reports checks.temporal == true.
  • GitGuardian and Snyk are green.

Checked locally at 76b155c:

  • Backend: the full suite gives the same 1,801 passed and 17 skipped, with zero serializer warnings. ruff check . and mypy biosim_server tests (240 files) are clean. scripts/generate_openapi.py shows no drift.
  • Frontend: the same five checks pass, with the same counts.
  • macOS compose: the smoke entry point was run against this compose.yaml in a disposable Compose project, changing only container names and ports. It exits 0. Mongo and Temporal end healthy, the init container exits 0, the server runs as uid/gid 1000, and the probe workflow survives --force-recreate.
  • Kustomize (v5.8.1): all four overlays render byte-identical to the PR base. With components: [../../components/verification-reconcile] added to a copy of biosim-gke, the render gains only the verification-report-reconcile CronJob: backoffLimit: 0, restartPolicy: Never, 240s deadline, Forbid, the overlay's namespace, its platform-api tag, the hashed api-config, and an emptyDir scratch volume.

Mutation checks re-run here. These are the six that the remediation plan's validation matrix requires. Each was applied in its own worktree, failed its tests, and was restored. Without mutations, the 73 tests involved all pass.

Mutation Failing tests
Persistence back to maximum_attempts=20 test_transient_failures_are_retried_until_the_report_is_stored
History scan removed test_terminated_while_persisting_both_writers_store_the_computed_output (inline and externalized)
GET writes a different failure message test_terminated_while_computing_writers_store_one_identical_failure
Reconstruction charged after the existence check test_a_throttled_reconstruction_does_no_storage_work, test_a_missing_snapshot_is_refused_and_stays_charged
Reconciler exits 1 when a row is blocked 6 tests in test_reconcile_job and test_persistence_ack
UUID-suffixed object names 8 tests: 7 in test_object_storage (reuse, sharing, retries, repair, restore) and test_oversized_statistics_crosses_activity_boundary_as_reference

CI runs: backend-ci, frontend-ci, smoke.

Each Phase 3, 4 and 5 commit passed the full backend suite on its own. Phase 2's 263b1f9 and 22e60f0 do not (see "Reading the Phase 2 commits").

After #162 merges, this PR is retargeted to main, and origin/main is merged in (or the branch is rebased if #162 is squash-merged). This validation is then repeated on the resulting SHA.

Mutation record

Each mutation was applied, its tests were run and failed, and the code was restored.

  • Phase 2: the 20-attempt cap restored, the history scan removed, a different GET failure message, prepare_report skipped, fresh provenance on recovery, the persist result keeping statistics, the ledger read swallowed, and the X-1 guard removed.
  • Phase 3: exiting non-zero on a blocked row, UUID-suffixed names, persist staleness from row age, an acknowledgement that ignores the error or the expected state, a 412 not treated as "exists", a repair that overwrites, no transient cleanup, no snapshot budget, no stored-report check on retry, and GC ignoring report references, live pointers or the grace period.
  • Phase 4: charging after the storage check, annotating a running poll, recovery back on a 200, an ungrouped derived listing, no daily cap or a cap checked after the charge, and a link that exposes its initiator.
  • Phase 5: a Mongo outage turned back into a plain 503, an outage NOT_FOUND answered 404, a stored result served from Temporal, a failed execution recovered during an outage, the outage answer skipping authorization, and backup reading a private collection.
  • Before the review, at 0407ef3: making the same-start confirmation always refuse; dropping derived links from the backend 404 or from the frontend's 404 handling.

Smoke failure evidence (remediation step 1.7)

The smoke entry point (scripts/smoke-infrastructure.sh infra, the one CI runs) was run on macOS (Docker Desktop 29.8.2) against the old def76af compose.yaml on a fresh named volume, in a disposable Compose project. It exits 1:

mongo ready
temporal did not become SERVING
... temporal ... Restarting (1) ...
Error: failed starting server: failed setting up schema: unable to create SQLite admin DB:
unable to open database file: out of memory (14)

SQLite cannot open its file because the fresh volume is root-owned and the server runs as uid 1000. With the fixed compose, the same entry point exits 0, and CI's smoke log shows temporal SERVING, temporal persistence probe survived container recreation and backend reports checks.temporal == true. Removing the init depends_on fails test_compose_drift.py. Full record: docs/pr-163-phase-1-validation.md.

Deployment safety (def76af)

Merging this PR deploys nothing to any cluster. All four overlays (biosim-gke, biosim-rke, biosim-local, biosim-rke-frontend-dev) render byte-identical to the PR base with kubectl kustomize.

  • The reconcile CronJob is opt-in. It was in kustomize/base, which every overlay includes. Flux would have created it in biosim-gke with platform-api:backend-0.10.3, which doesn't contain scripts.reconcile_verification_reports, so it would have failed every 5 minutes. It is now the component kustomize/components/verification-reconcile. The deploy PR that moves an overlay to a backend release containing the module adds components: [../../components/verification-reconcile] to it. Rendered with the component enabled, the CronJob picks up the overlay's namespace, its platform-api tag, and the hashed api-config name, and nothing else in the overlay changes.
  • Its /app/scratch is now an emptyDir (1Gi limit). It previously mounted worker-cache-pvc, which is ReadWriteOnce on biosim-gke and held by the worker, so a job scheduled on another node could never attach it. The path only holds one run's temporary downloads.

The runbook, backend/CLAUDE.md (deploy step 5) and the root CLAUDE.md layout describe the component.

⚠️ Before deploying

  1. Enable the reconcile component in the deploy PR, alongside the newTag bump, as described above.
  2. Temporal workflow change. Follow the worker-drain procedure in backend/CLAUDE.md, including flux suspend before the overlay merge. Run backfill while the 24h histories are still retained, and again after the drain. The patches keep old histories replayable; the drain is still required.
  3. Data converter change. The API and worker now connect with pydantic_data_converter. 0.10.3 used the SDK's default DataConverter. Compatibility with 0.10.3 payloads and histories is tested (test_converter_compat.py), so backfill can read retained 0.10.3 histories. The worker drain in step 2 is still required for the workflow-code changes.
  4. Local developers: the next docker compose up recreates platform-temporal with the new volume. Existing in-memory history is not migrated. Do not run docker compose down -v.

Known limitations (recorded, not changed)

  • When GET or the reconciler catches up a completed execution's result (for example a blocked one), it still builds fresh provenance without source_verification_id and derivation_kind. Reports recovered from history now keep them. Display is unaffected, because the link is read from the ledger row.
  • A narrow window remains in which GET returns a 500. The ledger lookup has to succeed, and then the second report read, on the branch for a Temporal result marked stored, has to raise an unclassified error: Mongo dropping between the two reads. A failure of the first lookup is the outage path above.
  • The rendered template has not been checked in a browser. The component tests use render = () => null.

Still open after merge (production acceptance)

  • Publish images; release PR, then deploy PR (enabling the reconcile component), with the worker drain and backfill.
  • Install the reconciler and both alert policies with an approved notification channel. Verify that a failure and its recovery are both delivered.
  • Record the production backup schedule, RPO and access policy, and complete one isolated restore from a real production backup.
  • Confirm that a newly deployed report stays readable after Temporal retention has actually removed its history.
  • Infra review of ops/storage/verification-lifecycle.json. Applying it replaces the bucket's lifecycle rules, so merge it with the existing ones. Confirm the service account has storage.objects.delete; without it, only the lifecycle rule removes transient objects.
  • Run scripts.gc_verification_objects (dry-run) against the production bucket and record its totals as the production storage baseline. The local baseline is all zeros, because the local store holds no verification objects.

🤖 Generated with Claude Code

NovrusShehaj and others added 4 commits October 29, 2026 13:21
- Updated `compose.yaml` to configure Temporal with a persistent SQLite database.
- Added new settings in `config.py` for Temporal namespace and verification report collections.
- Implemented verification report persistence activities in `activities.py` and integrated them into workflows.
- Enhanced error handling and logging for report persistence in `main.py` and `runs_verify_workflow.py`.
- Introduced new models for report persistence state and artifact references in `models.py`.
- Updated OpenAPI specifications to reflect new report persistence features.

This commit improves the reliability and traceability of verification reports in the system.
…y support

- Added new endpoints `/verify/{workflow_id}/reconstruct` and `/verify/{workflow_id}/reverify` to handle reconstruction and reverification of simulations.
- Introduced models for `ReconstructRequest`, `ReverifyRequest`, and `LinkedRecoveryAccepted` to support new workflows.
- Enhanced the `VerificationDatabaseService` with methods for managing recovery reservations and derived verifications.
- Updated OpenAPI specifications to include new endpoints and their parameters.
- Improved error handling for idempotency conflicts and recovery scenarios in the verification process.

This commit enhances the verification system's capabilities for handling complex recovery scenarios while ensuring idempotency and traceability.
- Updated the `/verify/{workflow_id}` endpoint to include `derived_verifications` and `recovery` offers in the response when the original report is unavailable.
- Modified the `_ResultsNoLongerRetained` exception to include derived verification links.
- Enhanced the GCS storage handling to map HTTP 404 errors to `FileNotFoundError` for missing objects.
- Updated frontend components to display derived verifications alongside original results, ensuring clarity in the verification status.
- Improved test coverage for derived verifications and recovery scenarios.

This commit improves the user experience by providing additional context for verification results and enhances the robustness of the verification system.
The CronJob lived in kustomize/base, which every overlay includes. Merging
to main would have made Flux create it in biosim-gke on backend-0.10.3,
which lacks scripts.reconcile_verification_reports, so it would fail every
five minutes until a release containing the module was deployed.

It is now kustomize/components/verification-reconcile. No overlay enables
it; the deploy PR that moves an overlay to a release with the module adds
the component. All four overlays render byte-identical to the PR base.

The job also mounted worker-cache-pvc, which is ReadWriteOnce and held by
the worker, so a pod on another node could never attach it. /app/scratch
only holds one run's temporary downloads, so it is now an emptyDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NovrusShehaj
NovrusShehaj requested a review from jcschaff October 29, 2026 21:43
NovrusShehaj and others added 19 commits October 30, 2026 13:57
…inistic

A verification report must never be a degraded version of a computed result,
and two writers that recover the same execution must produce byte-identical
content.  Both properties were broken in three ways:

* The GET path and the reconciler each synthesized their own FAILED report.
  The GET wrote "Verification workflow ended with status X" with the
  execution's close time; the reconciler wrote "Temporal execution ended with
  X" with the ledger row's creation time.  The first writer to insert won,
  so the second one hit a digest conflict and answered 500.
* Neither writer looked at the workflow history.  When a workflow computed an
  output and then failed to store it, the computed output was still in the
  history as the persist activity's input, and both writers threw it away.
* report_recovery imported api.main to read the start event, so the
  reconciler CronJob loaded FastAPI.

The history helpers now live in biosim_verify/history.py: read_start_input
decodes the start event, computed_output_from_history returns the input of
the last scheduled persist activity, and terminal_failure_output is the one
deterministic FAILED builder.  Both writers use them, so they agree on the
content and on the bytes.  The computed output wins: a terminal failure is
synthesized only when the history proves nothing was ever computed, so a
successful verification can never be replaced by a failure.

The persist activity now returns the slim result once its report is durable:
identity, status, error, failed_simulators, compare_settings, timestamps and
the STORED state stay, while workflow_results and workflow_results_reference
are cleared.  The stored report is authoritative, so statistics leave the
activity result, the workflow result and every query.  A BLOCKED result keeps
them, because that is the only copy of the truth.  This is what makes
test_real_temporal_and_mongo_roundtrip[completed] pass: the assertion and the
activity return belong to the same change.

A losing writer now serves the stored report instead of failing the request,
and a STORED result whose report is missing is an incident (503 with an
attention log), never a slim result dressed up as the report.
The persist activity call was duplicated in each verification workflow, and
every copy gave up after 20 attempts with a 1-minute maximum interval.  A
storage outage longer than that turned a computed result into a permanent
failure: the workflow completed FAILED even though the science had succeeded.

workflow_persistence.py now holds the single call site and the single policy.
Transient failures retry without a cap, so a computed result is never degraded
because storage was briefly unavailable.  A permanent failure (a ValueError, a
report integrity conflict, a corrupt artifact, or a missing artifact) completes
the workflow with the computed output marked BLOCKED: the verification itself
succeeded, and a workflow that keeps retrying also keeps its history, which is
what recovery reads.

The activity now classifies its own failures.  ReportArtifactMissing is
permanent and is listed explicitly because it subclasses ReportUnavailable,
which stays retryable: an unconfigured or unreachable store may recover, but a
missing object never appears later.  A ledger read failure is raised as
retryable instead of being swallowed, so a report is never stored with its
re-verification link silently dropped.

The capped_retry_runs fixture records a history under the old 20-attempt
policy, so the replay test proves in-flight and finished executions from the
previous build still replay under this one.
Statistics crossed Temporal in three places: the statistics activity result,
the persist activity result, and the workflow result and query.  A large
comparison could therefore exceed the payload limit even after its report was
safely stored.

generate_statistics_activity_v2 now returns statistics inline when their
encoded JSON is at most verification_activity_inline_max_bytes (512 KiB, a
quarter of Temporal's 2 MiB limit) and externalizes them otherwise.  Most
verifications then skip an object write and read and create no transient
artifact, and history recovery can read inline statistics even when object
storage is unavailable.

test_workflow_payloads.py decodes the persist ActivityTaskCompleted result, the
workflow result and the final query on a real worker and asserts none of them
carries workflow_results, that the stored report still does, and that the
inline threshold is inclusive at its boundary.
Deploying this branch replaces the worker that reads histories written by
0.10.3.  If the new converter cannot decode an old payload, every retained
history becomes unreadable at exactly the moment the drain procedure is
supposed to be safe.

test_converter_compat.py round-trips all 16 payload samples (every 0.10.3
workflow and activity type, with NaN, +/-inf, enums, datetimes and None) from
DataConverter.default to pydantic_data_converter, and decodes a history
written with the default converter through read_start_input, result() and the
Replayer under the current converter.  No decoding shim is needed, which is
recorded here rather than assumed.
The reconcile job exited 1 whenever a row needed attention, and its CronJob
retried a failed run in place.  One blocked report therefore failed every
scheduled run until someone intervened, and a failing job was
indistinguishable from a healthy job that had found work.

The exit status now reflects the job's health only.  A scan that ran,
complete or budget-limited, exits 0 and always logs
verification_reconcile_summary as the heartbeat; it adds
verification_persistence_attention (ERROR) only when a row needs an
operator.  A lease held by another run exits 0 with skipped="lease_busy".
Initialization failure, a lost lease and unexpected exceptions exit 1 with
verification_reconcile_job_failed, which carries the reason and the
exception type but never its message.

Staleness now comes from the execution's pending activities instead of the
ledger row's age.  A persist activity at attempt 3 or later is
persist_retrying and needs attention after --stale-persist-after (1h); its
age is a lower bound derived from the retry policy, because Temporal does
not report when the first attempt was scheduled.  Anything else running is
computing and needs attention only after --stale-running-after (24h), since
a comparison can legitimately take that long.

The CronJob uses restartPolicy: Never and backoffLimit: 0, so the next
5-minute run is the retry.  The heartbeat metric counts
verification_reconcile_summary, and the attention alert matches
verification_persistence_attention or verification_persistence_failure.
Every overlay still renders byte-identical to the base branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row that needs an operator, such as a report blocked by an encoding bug,
alerted on every 5-minute run until the report was stored.  The only way to
silence it was to change the data, which the immutability guarantee forbids.

The ledger row gains persistence_ack = {state, error, reason,
acknowledged_by, acknowledged_at}.  It applies only while the row's
persistence_state and persistence_error still equal the acknowledged values,
so a different failure brings attention back.  The acknowledgement never
deletes or changes a report.

scripts/ack_verification_persistence.py writes it.  It is dry-run by
default and prints the row's persistence fields (never its owner or saved
request).  It refuses stored rows, rows that are not in --expect-state, and
rows that change between the read and the conditional write.  --clear
removes an acknowledgement under the same rules.

The reconciler skips acknowledged blocked rows and counts them as
acknowledged; --include-acknowledged retries them after a fix release.
Other acknowledged rows, such as a stale one, are still processed so they
are saved when they finish, but they are not attention.  The attention
alert's instructions now describe both responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
upload_bytes_immutable gave every attempt a new UUID-suffixed object, so
each retry, each racing writer, and each verification of the same run data
left another copy behind.  Storage grew with attempts rather than with
distinct content, and nothing could ever tell the copies apart from
referenced objects.

FileService gains put_if_absent, exists and delete_file:
- GCS uploads with ifGenerationMatch=0, maps a 412 to "exists" beside the
  404 mapping, and compares the md5Hash the upload returns;
- local storage writes a temporary file and links it into place, so the
  name appears atomically and an existing one is never replaced;
- MinIO/S3 puts with If-None-Match: * (checking a single-part ETag), and
  falls back to check-then-put on servers without conditional writes.
  get_modified_date now maps a missing object to FileNotFoundError.

publish_content_addressed creates the object, or reuses an existing one
after checking that it decodes to the same canonical SHA-256.  An existing
object that does not match is never overwritten: the content is written to
a .repair-<uuid> name and verification_object_integrity_conflict is logged,
which the attention alert now matches.

Every former caller uses deterministic names from object_names.py:
statistics artifacts under verification-activity/{workflow_id}/{digest},
snapshot data and manifests by digest, externalized reports under
verification-reports/{report_id}/{digest}, and restores under the name the
report would have had, so a repeated restore reuses one object.
upload_bytes_immutable is removed.

The tests pin I-6 for publication: ten persist retries create no new
objects, two verifications of the same data share one data object, a
corrupt object takes the repair path, and FakeGcsStorage now honours
ifGenerationMatch.  Reverting to UUID names fails eight of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every verification left its statistics artifact and its snapshot index
pointer in storage forever, although neither is referenced once the report
is stored, and one very large verification could retain unbounded snapshot
bytes.

After store.save and record_report_persisted succeed, the persist activity
deletes, best-effort, the workflow's statistics artifact and snapshot index
pointer.  A failed delete logs verification_transient_cleanup_failed and is
not fatal; the lifecycle template below is the backstop.  Because the
pointer is gone afterwards, provenance now records
snapshot_manifest_reference, which keeps the manifest referenced.

Deleting those objects makes a retried persist activity dangerous: a worker
that dies after the cleanup would retry, fail to hydrate the deleted
statistics, and block a report that is already stored.  An attempt that
finds the report already stored for the same run and owner therefore only
acknowledges it.  A test pins this; removing the check fails it.

verification_snapshot_max_bytes (64 MiB uncompressed) bounds one
verification's snapshots.  The budget is checked before any upload.  Over
it nothing is captured, verification_snapshot_capture_skipped is logged, the
pointer records the skip, provenance carries input_snapshots_skipped, and
the reconstruct offer is refused as work_limit instead of
missing_numerical_inputs.

ops/storage/verification-lifecycle.json deletes objects older than 30 days
under verification-activity/ and verification-snapshots/index/ only.  It is
not applied automatically.  Its README notes that applying it replaces the
bucket's lifecycle configuration, and that without storage.objects.delete
only this rule cleans up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deterministic names and transient cleanup stop new garbage, but nothing
removed what had already accumulated: the UUID-suffixed objects of earlier
attempts, manifests whose pointer is gone, and losing report attempts.  No
lifecycle rule can collect those prefixes safely, because snapshot data
and reports are long-lived and shared.

scripts/gc_verification_objects.py collects by reachability, dry-run by
default.  The reference set is every object a stored report names (its
payload, its snapshots and, now, its manifest) plus everything a live
snapshot index pointer reaches, which covers verifications still
persisting.  Candidates are unreferenced objects under
verification-snapshots/{data,manifests}/, verification-reports/ and the
legacy verification-restored/ prefix that are older than --grace (7 days).
--apply deletes them oldest first, capped by --max-deletes; it rebuilds
the reference set just before deleting, so an object reused during the
listing is kept, and it refuses to delete while a live pointer cannot be
resolved.  Each run writes a 0600 report, never replacing an earlier one,
and prints per-prefix totals of objects, bytes, unreferenced bytes and
candidates as the storage baseline.

FileService gains iter_objects, which pages through a prefix; on GCS it
follows list_objects' pageToken instead of the unpaged get_listing.

Tests pin that a referenced object is never a candidate however old, that
apply deletes exactly the stale unreferenced objects and leaves the stored
report and its retained inputs whole, and that the cap, the refusal and the
late-reference rescue hold.  Removing the report references, the live
pointers or the grace period from the script each fails them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
accept_reconstruction downloaded, decompressed and checksummed up to 200
snapshot objects before it charged the shared workflow budget.  A caller
already over the limit could still make the API read and parse every
retained input of a public verification, on every request.

Admission now runs cheapest first:
1. the idempotency peek;
2. the provenance read and the in-memory eligibility and input checks;
3. the shared-budget charge;
4. an existence check of each distinct snapshot reference, 8 at a time
   within 10s, with metadata calls only;
5. the reservation and the start.

_reserve_workflow_id no longer charges; _charge_new_reservation does, only
for a reservation that does not exist yet, so an idempotent replay is
still free.  A missing snapshot is still a typed 409 that starts nothing,
but the charge stands because storage was read.  A storage error or a blown
budget is a 503.  Re-verification is charged before its archive lookup
for the same reason.  _verify_snapshot_bytes is gone; load_verified_snapshots
runs only in the comparison activity.

Contract change: a checksum mismatch is no longer a 409.  The comparison
activity raises a typed, non-retryable error, and the derived report fails
with failure_code "checksum_mismatch" (or "missing_numerical_inputs") and an
exact workflow_error.  VerifyWorkflowOutput gains failure_code, and the
OpenAPI spec is regenerated.

Tests spy on every FileService call: a throttled caller gets 429 with none,
an eligible request makes only existence checks, and a missing snapshot is
a charged 409.  On a real worker, a tampered snapshot gives a derived
FAILED report with checksum_mismatch.  Moving the charge after the existence
check fails the spy test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every GET /verify/{id} built recovery offers: a provenance read, an archive
lookup and a storage metadata call, plus a second ledger read, on every
progress poll of a running verification as well as on every finished
report.  The UI then showed recovery buttons on healthy and running reports.

A report response now carries no recovery:
- A verification that is still running gets progress only.  A poll makes
  no ledger, provenance, archive or storage call beyond what the progress
  itself needs.
- A finished report is annotated with its derivation fields and derived
  links, using the ledger row the request already read; it is not read
  twice.

The new GET /verify/{id}/recovery computes the offers when a caller asks
for them.  It has the same visibility as GET: a hidden verification is a
plain 404, and the response is private, no-store.  build_recovery takes the
provenance its caller already has instead of reading it again.  The
unavailable-original 404 keeps its recovery offers, since that is where they
are offered unprompted.

The optional ETag is not added.  A report's content_sha256 does not cover
the derived links annotated onto the response, so a 304 could serve a
stale list.

Tests cover: a 200 has no recovery; the /recovery access matrix (public,
owner, admin, other, anonymous, unknown); a running poll makes no
provenance, OMEX, storage or derived-link call (spies); and a finished
report reads its ledger row once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A public verification's permalink listed its derived verifications oldest
first, the first 100 only.  Anyone could start them without limit, so a
flood of requests could push the owner's and admins' links off the page,
and nothing showed who had started a link or whether it used arbitrary
tolerances.

Every derived row now records initiator_relation (owner, admin, other or
anonymous) at reservation time; admin is decided then, since roles change.
It also records custom_settings: whether a re-verification's tolerances or
observables differ from the documented defaults.  Links expose the relation,
a per-caller is_yours and custom_settings, never an identity.  The derived
report itself carries custom_settings too.

list_derived sorts newest first, takes initiator filters, and returns
{items, total, truncated}, served by a new (source_verification_id,
created DESC) index.  The permalink lists:
- every owner- and admin-started link, capped at 10;
- the caller's own, capped at 10;
- the 5 newest started by anyone else.
derived_verifications_total gives the overall count, so the UI can say
"N more".  The unavailable-original 404 lists them the same way.

One caller may start at most 10 derived verifications of one source per
day, or 3 anonymously.  The count comes from BiosimVerifyIdempotency via a
(caller_key, source_verification_id, created) index and is checked before
the shared workflow budget, so a capped request costs nothing.  Exceeding
it is a 429 with code derived_limit.  An idempotent replay is neither
counted nor charged.

The optional GET /verify/{id}/derived?cursor= is not added.

Tests: 105 links from others never displace owner, admin or the caller's
own; owner and admin links are capped at 10; both caps hold, per source and
for a day only; the capped request is never charged; custom settings are
flagged; and no initiator identity appears in any response.  Ungrouping the
listing, dropping the cap or checking it after the charge, or exposing the
initiator each fails them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The verification panel showed "Reconstruct comparison" and "Start
re-verification" whenever an offer was eligible, including on healthy and
running reports.  The re-verify form only displayed its defaults, so a
caller could not choose simulators or tolerances.

Where recovery actions appear now:
- An unavailable original shows them automatically, from the 404.
- A finished COMPLETED or FAILED report shows a collapsed "Recompute…".
  Opening it calls GET /verify/{id}/recovery once, lazily.
- Nothing shows while a verification is running or saving.
recoveryMode, beside verificationIsDone, decides this in one place.

The re-verify form is editable:
- a simulator multi-select seeded with the defaults and the suggested
  versions, which accepts a typed name or name:version;
- relative and absolute tolerances;
- include outputs;
- the description.
Each field shows its documented default.  Client-side bounds mirror
ReverifyRequest: 1 to 10 distinct simulators, and finite tolerances of at
least 0.  ReverifyRequest now enforces that lower bound too: a negative
tolerance would fail every comparison, and is a 422.  An invalid draft is
never sent, and the idempotency key stays unique per submitted body.

Derived verifications are grouped as owner or admin, yours, and others.
The panel says how many more are not listed, and shows a "Custom
tolerances" badge on links and on a derived report whose settings differ
from the defaults.  The 200 response no longer carries recovery, so the
panel stops reading it.

frontend/app/models/verification.ts gains the new fields (failure_code,
derived_verifications_total, custom_settings, the link fields and
ReverifyRequest), and the OpenAPI spec is regenerated.

Tests:
- recovery visibility for running, saving, stored, failed and unavailable
  reports, in the real component and in recoveryMode;
- the lazy /recovery load happens once;
- edited settings reach the request body, and invalid ones are not sent;
- client-side bounds;
- derived grouping with "N more";
- backend: a negative tolerance is a 422, and a derived report carries
  custom_settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
report_backup.py reached into the report store's and the ledger's private
collections, object storage and settings to export and restore.  Any change
to how either class stores data could silently break backups, which nothing
else would notice until a restore was needed.

The report store gains iter_envelopes, read_artifact, check_envelope and
restore_envelope; the ledger gains iter_rows and restore_row.  Backup and
restore now go through those alone.  restore_envelope keeps the existing
rules: it never replaces a report, an identical one is left alone, a
different one is an integrity conflict, an externalized payload is
published under its deterministic name, and the restored report is read
back before it returns.

A grep-based test fails if any production module touches another object's
_collection, _file_service, _idempotency, _maintenance or _settings, and if
report_backup.py issues a collection call of its own.

derivation_kind is now DerivationKind | None on the report, its provenance
and the ledger row, and code compares it with enum members instead of
"reconstruction".  The enum serializes to the same string, so stored rows
and content hashes are unchanged and rows written as plain strings still
validate; a test pins both.  models.py uses `import re` instead of
__import__("re").  The OpenAPI spec is regenerated for the typed field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any exception from the saved-report lookup made GET /verify a 503, so a
Mongo blip stopped every progress poll, even for verifications whose state
lives entirely in Temporal.  The original plan's lookup contract (§5)
promised a Temporal fallback for exactly that case.

When the ledger or report-store lookup raises a dependency error (anything
but ReportCorrupt or ReportUnavailable), GET now continues to Temporal and
answers only what Temporal alone can answer truthfully:
- a running verification returns its progress query, authorized from the
  workflow's own owner_sub (401/403, as before reports were saved), with
  Cache-Control: private, no-store;
- a completed result that was blocked or still pending is returned marked
  pending, so the client keeps polling;
- a stored (slim) result, a failed execution, and a workflow Temporal does
  not know are a 503 with Retry-After: 5.  A stored report can only be read
  from the report store, and the ledger is what decides "no longer
  retained", so the answer is never a 404 during an outage.
Nothing is written while the ledger is down: no catch-up publish, no
terminal-status record, no annotation.  A corrupt report is still a plain
503, not an outage.  The NOT_FOUND ledger fallback's own 503 also sends
Retry-After now.

The trade-off: stored reports depend on Mongo, and running progress does
not.

The outage matrix covers:
- running progress;
- blocked and pending completed results;
- a stored result;
- the four failed statuses;
- NOT_FOUND at describe and at query;
- private access for anonymous, other, owner and admin callers;
- a report-store-only outage;
- a corrupt report.
Each case also asserts that nothing was written.  Reverting to a plain
503, letting NOT_FOUND 404, serving a stored result from Temporal,
recovering a failed execution, or skipping authorization each fails a
test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runbook, the architecture notes and the backend guide still described
the branch as it was before the review: a 20-attempt persistence retry,
UUID-suffixed objects with no cleanup, a reconciler that exits nonzero on
any row problem, recovery offers on every poll, a checksum mismatch as a
409, and statistics that "never travel through Temporal payloads".

docs/verification-report-retention.md now covers uncapped persistence and
BLOCKED (including that a blocked result must be stored before its
history expires), the corrected payload claim, the data converter change,
the Mongo-outage answers, the compose init service and developer
migration, reconcile exit semantics, events and the acknowledgement
procedure, and the object layout, lifecycle rule and GC procedure.

docs/workflows-architecture.md gains a persistence and history-recovery
section and rewrites linked recovery: the admission order, the checksum
contract, /recovery on demand and the derived-link policy.

backend/CLAUDE.md lists /verify/{id}/recovery and the changed GET and
recovery rows, the new modules, the operations scripts, and the
FileService additions. The root layout lists ops/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant