Backend/API Contract
The backend/API contract is the bridge between frontend design changes and runtime behavior. When a new surface appears in the frontend, the matching backend capability should be identified, confirmed, or added before the change is considered complete.
Review checklist
Section titled “Review checklist”- Confirm every new data view has a backend endpoint or a documented static source.
- Confirm every new action has a stable request path, method, payload shape, and response shape.
- Preserve existing endpoints, response fields, and error behavior unless a migration is explicitly planned.
- Add route coverage to the visual baseline list when a new user-facing surface is introduced.
- Keep desktop, laptop, browser, PWA, and mobile behavior aligned with the same contract.
- Confirm every client handles the shared request gates —
429(withRetry-After/X-RateLimit-*) and413— on any route, not just the one being added (v0.55.0, 2026-06-21).
Common surfaces
Section titled “Common surfaces”- SpectraCheck uploads and analysis results
- Raw FID archive preservation
- Regulatory records and evidence trails
- Reaction optimization workspaces
- Deployment health, readiness, and diagnostics
Cross-cutting request gates
Section titled “Cross-cutting request gates”These apply to every route, so they belong in the contract even though they add no endpoint. Both are settings-gated and default-OFF, so local dev and the test suite are unaffected; production enables them through the deployed service’s environment.
Rate limiting (v0.55.0, 2026-06-21)
Section titled “Rate limiting (v0.55.0, 2026-06-21)”An in-app token-bucket limiter keyed system-key/admin → unlimited | user:{id}:{route} | ip:{client_ip}:{route}. The per-user key is the per-tenant key today — the product is single-tenant-per-user and AccessContext carries no org id.
- Enforcement rides the router gates:
_baseline_access_gateon the main router (reusing the already-resolved principal — no duplicate token decode), plus a rate-limit-only gate on the SCIM and nmr2d routers so privileged provisioning and the heavy 2D-analysis routes are throttled too. - A throttled call returns
429withRetry-AfterandX-RateLimit-*; those headers are added toCORS_EXPOSE_HEADERS, so a browser client can read them. - Unauthenticated auth endpoints carry tight limits (login 10/min, sign-up and reset 5/min); everything else uses a generous default.
- A throttle emits a de-duplicated
SecurityEvent(event_type="rate_limit"). - Fail-open — a limiter error can never 500 a request. The in-process bucket map is bounded (idle-then-LRU eviction) so key rotation cannot exhaust memory; the
RateLimitStoreprotocol is the Redis drop-in seam. - Settings:
RATE_LIMIT_ENABLED(defaultfalse),rate_limit_default_per_minute,rate_limit_burst_multiplier,rate_limit_trust_forwarded_for.
Request-body size cap (v0.55.0, 2026-06-21)
Section titled “Request-body size cap (v0.55.0, 2026-06-21)”Non-multipart bodies larger than MAX_REQUEST_BODY_BYTES are rejected with 413. Multipart uploads are exempt — they have their own raw-archive caps. Default 0 (disabled).
A WAF is not implemented in-app: it is delivered as a Cloudflare/Vercel edge runbook, and the rate limiter is the testable in-repo enforcement (v0.55.0, 2026-06-21).
Public unauthenticated endpoints
Section titled “Public unauthenticated endpoints”| Method + path | Purpose | Notes |
|---|---|---|
GET /.well-known/security.txt | RFC 9116 coordinated-disclosure file, served text/plain to anonymous callers. | Expires is computed at request time and clamped into the one-year window, so a served file is never stale. Optional Policy / Canonical / Encryption / Acknowledgments URLs are emitted only when configured, so the file never advertises a page that 404s. Added to PUBLIC_ROUTE_PATHS, so it sits behind the same default-deny gate and IP-keyed rate limiter as /health. (v0.56.0, 2026-06-25) |
Settings: SECURITY_TXT_ENABLED (default ON — the route 404s when disabled), security_txt_contacts, security_txt_expires_days, security_txt_policy_url, security_txt_canonical_url, security_txt_encryption_url, security_txt_acknowledgments_url, security_txt_preferred_languages. Operator-supplied values are CR/LF-sanitized so an env value cannot inject a forged field line.
SpectraCheck NMR analysis endpoints
Section titled “SpectraCheck NMR analysis endpoints”The NMR analysis backend exposes a growing surface of typed endpoints. Each one writes a structured audit event so reviews remain reconstructible. The full capability detail with validation numbers lives in NMR Interpretation; the inventory below is the contract list a frontend or integrating system codes against.
| Method + path | Purpose | Audit event |
|---|---|---|
POST /spectrum/analyze/gsd | Opt-in Global Spectral Deconvolution; returns peaks + classifications + per-peak QC. | spectrum.analyze_gsd |
POST /spectrum/analyze/multiplets | Group GSD peaks into multiplets, recover J couplings, expose a synthetic forward modeller. | spectrum.analyze_multiplets |
POST /spectrum/analyze/integration | Quantitative region integration — Sum / Edited Sum / Peaks. | spectrum.analyze_integration |
POST /spectrum/predict/shifts | Predict ¹H / ¹³C shifts (ppm) + per-atom uncertainty (NMRNet or HOSE-code fallback). | spectrum.predict_shifts |
POST /spectrum/retrieve | FAISS HNSW similarity retrieval — top-k nearest reference spectra by L2 distance. | spectrum.retrieve |
POST /spectrum/reason | Retrieval-augmented reasoning — retrieve precedent, then propose verifier-arbitrated candidate structures (graceful degradation when the index or model backend is absent). | spectrum.reason |
POST /candidates/compare/jcoupling | Multiplet J-coupling unified-confidence bridge; per-candidate agreement labels + contradiction flags. | confidence.candidates.multiplet_jcoupling_bridge |
GET /spectrum/solvents/known | Canonical solvent catalog for FE dropdown validation. | — |
Admin / operations endpoints (GSD soak loop)
Section titled “Admin / operations endpoints (GSD soak loop)”These endpoints support the per-tenant graduation rollout for the opt-in GSD backend. See Deployment & Hosting → GSD experimental backend rollout for the operational policy.
| Method + path | Purpose |
|---|---|
GET /spectrum/analyze/gsd/telemetry-summary?window_days=N | Aggregate rollup — invocations, error rate, median/p95 wall time, solvent auto-detect rate, plus flip_readiness_verdict + flip_readiness_reasons + flip_readiness_policy + graduated_user_count + newly_graduated_in_window. Accepts an admin-only ?actor_user_id=<id> for per-tenant scope. |
POST /admin/users/{user_id}/gsd-graduation | Admin action — graduate or ungraduate a tenant out of experimental: true, with a required reason (1–500 chars). |
GET /admin/users/{user_id}/gsd-graduation-history | Full graduation history for one tenant — every graduate / ungraduate decision with the admin’s documented reason, newest-first. |
Admin / operations endpoints (MLOps)
Section titled “Admin / operations endpoints (MLOps)”Two read-only, admin-gated endpoints surface the Prompt 18 ops layer (see AI Model Lifecycle → MLOps) to the dashboard. Contract change (v0.21.1) — regenerate schema.d.ts.
| Method + path | Purpose |
|---|---|
GET /admin/ops/deployment-gate | The release-control posture, computed live: fails_closed (invariant), the gate’s self_check result, the four-check policy (dominance / audit_chain / tests_green / data_leakage), the output-contract schema version, and the monitoring thresholds (PSI / override / confidence bands + latency SLOs). |
GET /admin/ops/model-lineage | The model-lineage dashboard — per production model: version, training-snapshot hash, gold metric vector, promotion record, supersession, and drift status. Returns a typed empty dashboard until a registry is wired and a model is promoted. |
Admin / operations endpoints (security detections & SIEM)
Section titled “Admin / operations endpoints (security detections & SIEM)”Two admin-gated (require_admin, behind the default-deny gate) endpoints turn the immutable SecurityEvent stream and the tamper-evident audit chain into near-real-time detections. Contract change (v0.58.0, 2026-06-26) — regenerate schema.d.ts.
| Method + path | Purpose |
|---|---|
GET /admin/security/alerts | Read-only detection scan — runs the rules over the recent event window and returns the alerts without shipping them. |
POST /admin/security/detections/run | Scan and ship to the SIEM sink. This is the cron hook. |
Four pure detection rules back both routes: impossible_travel (same actor, two login_success from different IPs inside a window — an IP-velocity heuristic; geo enrichment is a documented seam), privilege_escalation (an is_admin flip), cross_tenant_access (≥ threshold cross_tenant_denied inside a window — enumeration probing), and audit_chain_break (ledger verification fails).
Contract detail callers and operators need (v0.58.0, 2026-06-26):
- New response models
SecurityAlert,DetectionScanResult, and theDetectionIdenum. SecurityEventTypegainscross_tenant_deniedandprivilege_escalation, so any consumer that switches exhaustively over the event vocabulary must handle them.- Emission is wired into
api.pyat the three password-login routes (login_success+ client IP), the three owner-scoped deny branches (cross_tenant_denied; anonymous and system principals skipped), and the three login admin-email auto-grant sites (privilege_escalation). MFA/SSO login and the explicit admin-grant route are documented seams, not yet emitting. - Sinks: always a
JsonStdoutSink(structured{"siem_alert": …}to stdout → the platform log drain → any SIEM), plus aWebhookSinkwhenSECURITY_ALERT_WEBHOOK_URLis set (best-effort POST, never raises). Only error/critical alerts ship. - Settings:
SECURITY_SIEM_ENABLED(default on — no behavior change whenfalse),SECURITY_ALERT_WEBHOOK_URL, plus the detection window/limit and the impossible-travel / cross-tenant thresholds. Emission is best-effort: a telemetry failure can never break the instrumented request.
Shipping to a hosted SIEM and running a 24/7 on-call rotation are operational, outside the repo; the rules, the sink seam, and the scan endpoints are the in-repo contract.
Endpoints that incident response depends on
Section titled “Endpoints that incident response depends on”The incident-response plan maps its containment levers onto endpoints and store functions that already exist, which makes them load-bearing contract rather than incidental (v0.59.0, 2026-06-26): revoke a session family, revoke_all_user_tokens, SCIM deprovision, POST /admin/users/{id}/demote, and API / audit-signing key rotation. Its forensic evidence sources are likewise existing surfaces — audit-chain verify and search, the SecurityEvent stream, debug bundles, soft-delete retention, and e-signature verify. Treat all of them as stable; a breaking change to any one degrades the documented response path.
Regentry endpoints
Section titled “Regentry endpoints”Regentry exposes the deterministic impurity engines and the dossier workflow. The whole /regulatory/dossiers/{id}/… surface is owner-scoped per user (own-or-system/admin); a non-owner read or write returns a non-leaking 404 (see Regentry → Access control).
| Method + path | Purpose | Audit event |
|---|---|---|
POST /regulatory/impurities/assess | Unified impurity assessment — Q3A/B thresholds, Q3C solvents, Q3D elementals, M7 mutagenicity, FDA CPCA nitrosamine, and cumulative-risk in one call from a product context; per-impurity failures degrade to warnings, never a 500. Contract change (v0.23.1). | regulatory.impurity.assess |
POST / GET /regulatory/dossiers/{id}/elemental-impurity-assessment | ICH Q3D elemental-impurity assessment on a dossier (route-dependent PDEs; reads the dossier route). Contract change (v0.23.4, migration 0014). | regulatory_compliance.elemental_impurity_assessment.create |
GET /regulatory/dossiers/{id}/nitrosamine-cumulative-risk | FDA-Rev-2 cumulative-risk rollup over a dossier’s nitrosamine watches (sum(measured / AI) < 1). Contract change (v0.23.5). | — |
GET /regulatory/dossiers/{id}/readiness-report | List a dossier’s readiness reports, newest-first, so the workspace can rehydrate the latest. Contract change (v0.24.5). | — |
GET / POST /regulatory/dossiers/{id}/ai-decisions · …/{entry_hash}/review · …/ai-decisions/verify | EU GMP Annex 22 (draft) AI-decision hash chain — record, list, HITL-review, and verify a per-dossier tamper-evident decision log. Contract change (v0.24.7, migration 0016). | regulatory.ai_decision.* |
The existing dossier assessment endpoints (…/residual-solvent-assessment, …/impurity-risk-register, …/nitrosamine-watch) now compute via the engines (v0.23.2) and source the dossier’s product context — max_daily_dose_g / substance_type (v0.23.3, migration 0013) and route (v0.23.4) added to the dossier models. The legacy-override is backward-compatible (tenant rule-rows still win when present), so v0.23.2 needs no schema.d.ts regen; v0.23.3 / v0.23.4 add the dossier fields and do.
Reaction optimization endpoints (Phase C)
Section titled “Reaction optimization endpoints (Phase C)”Phase C wires only the reaction surfaces that work with no heavy dependency installed. The generative heavy paths (AiZynth route proposal, RXN / transformers forward prediction, torch GNN training, SDL execution) are deliberately not exposed and stay unwired until the site extras and an off-request worker exist. Contract change (v0.63.0, 2026-07-23, migration 0031, three tables) — regenerate schema.d.ts; the release ships its own frontend handoff note (docs/fe_handoff_reaction_phase_c.md).
The per-project routes are owner-scoped and sit behind the reaction module gate. The two global routes are not project-scoped. Capability detail lives in Reaction Optimization → Phase C engines and the capability readout.
| Method + path | Purpose |
|---|---|
POST / GET /reaction-projects/{id}/yield-predictions · …/{run_id} | Fit a lightweight surrogate on the project’s own completed experiments and score submitted candidate conditions. The backend and its capability decision are recorded verbatim; degraded conditions are disclosed in per-prediction warnings. |
POST / GET /reaction-projects/{id}/route-scores · …/{score_id} | Score a chemist-supplied route (native or AiZynth-shaped tree) with the frozen safety / green engines; a Mermaid render is persisted with the record. Needs no optional dependency. |
POST / GET /reaction-projects/{id}/forward-checks · …/{check_id} | Cross-check a supplied forward prediction against the frozen engines before anyone acts on it. Needs no optional dependency. |
GET /reaction-capabilities | Global, stateless honesty readout of the governed heavy-ML capability table — per capability: enabled, available, active, missing_modules, reason, provenance, engine. Not project-scoped. |
GET /reaction-sdl/status | Read-only SDL site status (enabled, the capability row, execution_surface_wired: false, a plain-language detail). Not project-scoped. |
Contract detail callers need:
- There is no SDL execution surface. No arm, run-step, or abort route exists, and a registered-routes test pins that absence — treat any client that expects one as wrong, not as awaiting an endpoint.
- Error mapping. A capability that is flag-off or missing its dependency maps to
503. Recognised engine and parse errors surface as a400rather than a generic server error. - The three record types each carry a
disclaimerand (route scores and forward checks)human_review_required. These are part of the response contract; any surface that renders them must preserve them. - The surface is covered by API tests, including migration
0031upgrade / downgrade idempotence and the registered-routes test that pins the absence of an SDL execution surface.
AI inference feedback
Section titled “AI inference feedback”The closed-loop feedback surface lets a reviewer rate an AI prediction and, optionally, tag why it was wrong — the structured reason rolls override analytics up where the model is weakest. Full detail in AI Model Lifecycle → Closed-loop feedback.
| Method + path | Purpose | Audit |
|---|---|---|
POST /ai/predictions/{id}/feedback | Record reviewer feedback on an AI prediction — a thumbs verdict plus an optional structured reason_code (wrong_shift / wrong_multiplicity / wrong_structure / missed_impurity / wrong_integration / calibration_off / other). Optional, nullable, additive. | prediction-audit fan-out |
The reason_code field is a contract change (v0.19.1) — regenerate schema.d.ts after this release.
The 401 / 403 body is part of the contract
Section titled “The 401 / 403 body is part of the contract”Until now the shape of a denial was a frontend concern. The /api/backend proxy replaces detail on every 401 and 403, so a browser never sees the backend’s prose on those statuses — but a client that talks to the API directly (a desktop build, a partner integration, anything that is not the SPA) inherited none of that. The confidentiality control lived in the frontend, which meant it did not exist for anyone who did not go through it.
Most of it was already server-side: the sanitizer has always replaced the detail on both statuses for every input, with no environment switch and no way for a caller to ask for the verbose form. What leaked was a bypass set — three exception handlers registered beside the sanitizing one that returned the raiser’s prose verbatim, plus two carve-outs inside the sanitizer itself. Those are closed and the sanitizer is now total.
Off the wire as a result: MFA prose (“Invalid authentication code.”, “Incorrect password.”), two environment-variable names the built-in web page was rendering, and “Passkey clone/replay detected (sign count did not advance)” — which told the holder of a cloned credential that the clone had been caught and named the check that caught it. The refusal is unchanged; only the telling goes, and the person who owns the credential learns of it through the audit chain and the account’s notification path, which reaches the owner rather than whoever holds the copy.
What callers must know:
- Branch on
code, never ondetail.detailis now one fixed sentence per status. Five new registered public codes exist, because a total sanitizer without them is a regression — a user typing an authenticator code would otherwise be told “Sign in to access live MolTrace data”:credentials_invalid,mfa_required,mfa_enrollment_required,mfa_factor_invalid,feature_not_enabled.PUBLIC_CODESgoes from 9 to 14.mfa_requiredandmfa_enrollment_requiredare split because they need different screens — step up with the factor you have, or enrol a first one. - The 403
detailpassthrough is gone. A 403 whosedetailwas exactly a public code used to be echoed verbatim so a client could branch on the prose. Keeping it meant every newly registered public code silently widened what a 403detailmay say, as a side effect of registration. - There is deliberately no client-declared “direct mode”. A denial body reaches exactly one party — the caller who was denied — so any discriminator that caller sets is one they can omit, and a control an adversary can switch off is not a control. The inversion is to withhold from everyone, which costs nothing because no consumer wanted the verbose form. Where an operator genuinely needs the cause it goes to the log with the correlation id, carried in a field that is never serialised, so “the flag name does not reach the wire” is a property of the type rather than a discipline every future raise site has to remember.
- SCIM is the one handler deliberately not sanitized. Okta and Entra parse the SCIM
Errorenvelope, and reshaping it would break provisioning for every customer on enterprise SSO. It is pinned by a test, because “sanitize every handler” is the natural reading of the rule the other three now follow. - The pair is declared in OpenAPI as a shared
components/responses, not inlined. Inlining the 401/403 body on ~900 operations grew the generated frontend types by 24.3 %, past the 15 % budget; the hoisted form carries the same information for 4.81 %.
Also fixed while enumerating the readers: the frontend’s step-up detection branched on detail and therefore never fired in production, so the step-up ceremony the e-signature create path depends on never ran. Its tests passed because they built a body no browser is ever sent. Contract change (v0.76.0, 2026-08-22) — regenerate schema.d.ts.
Version currency and offline entitlement
Section titled “Version currency and offline entitlement”Two authenticated surfaces added for installations that run away from the workspace — a desktop build, or any deployment that must prove what it is running.
| Method + path | Purpose |
|---|---|
GET /system/active-versions | The workspace’s adopted configuration as four comparable coordinates: the five deterministic rule sets, whatever the model registry currently resolves as serving, the reference pack by digest, and the compiled-in method constants as one content address. Authenticated, because the catalogue describes a customer’s validated configuration. |
POST /desktop/entitlement-statements | Issue a signed entitlement statement an installation verifies offline. |
GET /desktop/entitlement-authority | The deployment’s own issuing authority — the certificate an installation checks a statement against. |
Contract detail callers and operators need:
- Currency is a state, not a gate on reading. An installation that is ahead of the workspace computes and exports with the adoption gap stamped on the record, and cannot sign. Being ahead is the expected steady state rather than a rare race — a desktop auto-updates on the vendor’s schedule while a validated deployment upgrades only in a requalification window — so refusing would take every installation out of service on a cadence the customer does not set. A refusal produces no record; a stamp produces an attributable one, and 21 CFR Part 11 bites at signature. Behind and unknown refuse unchanged. Reading, exporting and verifying existing records are untouched by any currency state, asserted structurally: no function other than the catalogue’s own route may consult it.
- The catalogue is signed as a separate document from an entitlement statement. Binding content versions into a commercial credential would make every rule-set release invalidate outstanding entitlements, and would let a content release expire something — which nothing should. (v0.78.0, 2026-08-22)
- Nothing about a statement is stored server-side — no statement table, no issuance table, no key table. A statement is derived from configuration plus the device row, signed, and returned; a stored statement would be a second source of truth that can disagree with what it came from, and the signature already carries the fact. Issuance and refusal land in the existing audit trail, and a test asserts no key material reaches it.
- Withdrawing offline use is expressed by declining to reissue, and that path did not previously work for the person expected to perform it: the device-session route is owner-scoped, so an administrator revoking someone else’s installation got the non-leaking 404 and the installation was never withdrawn. The fix is scoped to that one transition and resolved through the policy engine rather than by reading a role at the call site — relaxing the predicate instead would have handed an administrator every write on any installation, an authority expansion wearing a bug fix. Refusal is decided by an allowlist over device status, not a denylist, so a status added later is not a grant by default. Migration
0051adds the device identity key, nullable and not backfilled: a device enrolled before the column existed has no provable identity, and inventing one would assert something that never happened. - Two commercial terms have no defaults — how long an installation may work offline, and how long a statement lasts. Neither has been measured, and a plausible-looking round number would be signed into every statement, so unset the deployment declines to issue and names the missing decision. (v0.77.0, 2026-08-22) (The upstream changelog entry for this release cites migration
0050; the migration on disk is0051_device_identity_key, and0050is a different change.)
FID processing contract
Section titled “FID processing contract”- A structure is an advantage on the FID path, not an entry fee.
POST /raw-fid/{archive_id}/processno longer requiressmiles. With a structure, unchanged; without, identical processing, and the structure-dependent analysis fields are null — absent rather than a placeholder verdict, because verification means “does this spectrum match this structure” and there is nothing to match. The integrals that come back without one are ratios, not proton counts; see qNMR → Relative integrals are not proton counts. Contract change (v0.68.9, 2026-08-08) — regenerateschema.d.ts. GET /fid/presetsreturns each processing-preset id with its label. It exists because the machine-readable vocabulary must live somewhere other than an error message: a rejected preset used to dump the whole accepted-id set intodetail, which the frontend renders directly to a user — thirteen raw ids, including three internal shorthands, as copy a person reads.detailis now prose with nothing to go stale, and the signal a client needs did not disappear with the list:unknown_processing_presetis a registered error code, and a raise site can state a specific code beside prose rather than insidedetail. A structureddetailis not a third option, because the frontend would render it as[object Object]. Pinned by a test asserting the copy carries no engine id, no endpoint path, no status code and no_jsonfield name. (v0.69.8–v0.69.9, 2026-08-15)- Repeat processing is served from a persisted cache. Derived preview reports persist in
raw_fid_report_cache(migration0048, additive), keyed by the same content-addressed processing identity as the in-process cache, so a scale-to-zero restart or a different autoscaled instance serves a repeat request in ~0.09 s instead of recomputing. Cache failures degrade to recompute, never to a request failure. (v0.69.8, 2026-08-15)
Knowledge corpus endpoints
Section titled “Knowledge corpus endpoints”Governance surfaces over the curated regulatory corpus. Detail in Regentry → Regulatory knowledge corpus and AI Model Lifecycle → Corpus governance.
| Method + path | Purpose |
|---|---|
GET /knowledge/sources/{source_id}/revisions | The immutable revision history of a source. Records bind to the revision they were read from; the source id keeps meaning the living source. (v0.68.4, migration 0045) |
POST / GET /knowledge/dataset-versions/{id}/approvals | Two-person promotion approval. The approver is the authenticated principal, never a caller-supplied value; a unique constraint on (version, approver) makes one human with two sessions still one approver, and a machine credential is refused outright. Patching status straight to approved now fails. (v0.68.10, migration 0046) |
POST / GET /knowledge/deployment-candidates · …/{id}/gate · …/{id}/canary · …/{id}/promote | The gated conveyor: a candidate needs a two-person approved version, a canary needs a passed gate, and promotion needs a canary even when the gate passed. The gate reuses the platform’s existing fail-closed A/B rule rather than growing a second one. (v0.68.10, migration 0047) |
Access-scope changes callers may notice
Section titled “Access-scope changes callers may notice”- Compound registry reads are owner-scoped, with shared as a setting. Probed live, a second account could read another account’s preferred name, registry id and InChIKey, and find the row by searching the registry id. This reversed a deliberate, documented decision — that a compound registry is a shared reference, and closing reads would break the feature rather than secure it — which is right for one lab and wrong as a default for a hosted product, where a compound’s existence under a code name is confidential long before its structure is. So the single-lab case became
COMPOUND_REGISTRY_VISIBILITY=sharedrather than a casualty; the default isowner. Three things were only visible past the reads: the knowledge graph needed its own handling (an edge merely touching another tenant’s compound would print that tenant’s compound name, so an edge is dropped unless every endpoint on it is visible); eight write functions resolved their target through the same unscoped helper, so a stranger could hang an alias or evidence link off a compound they could not read, with 201-vs-404 confirming it existed; and the access error did not inherit the module’s base error, so every correctly refused attach would have been a 500. Refusals are 404 wherever existence is the secret. (v0.68.9, 2026-08-08) - A FID run is reviewed by a colleague, not by IT. All four review routes required the ADMIN role, so the chemist who ran an analysis could not have it reviewed unless a platform administrator did it — while the sibling SpectraCheck review route already used the ordinary access context, so the two surfaces disagreed. Any authenticated user may now review except the run’s own author; admins and the system key keep the override. Self-review is
409, not403: the caller is entitled to review runs, just not this one, and a 403 would be swallowed by the global access-denied sanitiser — which is what made the original refusal read as a broken feature. A run with no recorded author stays reviewable, the opposite call from managed files, where a NULL owner means refuse; there reading is disclosure, here refusing would obstruct every historical run for no gain. (v0.68.9, 2026-08-08)
Usage events behind the ROI figures
Section titled “Usage events behind the ROI figures”/analytics/roi derives every figure from usage-event rows, and the create function had exactly one caller in the whole backend: the HTTP route that exists to be called from outside. No analysis, job, workflow, report or review path emitted anything, so an instance could run analyses indefinitely and the Automation ROI page correctly rendered — forever. No frontend change could fix that — the only way to populate the page without this work was to fabricate the numbers.
- Emit sites live inside the transaction that records the work (8 in the first pass, 31 more in the second), so an analysis cannot commit without its event. The stateless MS / LC-MS / preview routes persist nothing, so there is no transaction to join and the usage event is the only record that the work happened; the emitter accepts either an open session or a session factory so the transactional guarantee still holds everywhere the work is written.
- The minutes saved are resolved from the versioned automation-task definition, never passed per call site — which is what makes the figure answerable when a customer asks where it came from. Catalogue seeding runs per missing key rather than only into an empty table, or a task added in a later release would never reach an instance seeded by an earlier one and every event resolving to it would be silently worth zero minutes.
- Double-counting was the failure mode to design against, because on a page showing
—it is indistinguishable from no-counting. A successful batch emits one event per item and none for the job wrapping them; a review task emits only on the transition into its resolved state; the closed-loop reaction task emits once, on confirmed outcome, because billing recommendation, execution and outcome would charge one loop three times; and workflow orchestration is scoped to exclude its steps, so a workflow whose QC step is itself an automated task composes to 45 minutes rather than double-counting 30. - Two id-space collisions were avoided deliberately — reaction project ids are not written to the field that addresses SpectraCheck projects, and an analysis-job id is not written to the field that addresses jobs and feeds the failed-jobs counter. A third was caught by the type checker: a regulatory dossier’s sample id is an integer foreign key while a usage event’s is a free-text sample label, and coercion would have filed the row under a sample named “7”. Two structural tests close the failures that are invisible by inspection: every catalogue entry must have an emitter, and no event type may match more than one ROI counter substring.
- No backfill, deliberately. Synthesising events for work completed before this existed would put invented history behind a number a customer may quote. ROI legitimately starts from the day it ships. Worth knowing before quoting these numbers: preview endpoints are cheap and idempotent, so a user tuning parameters re-invokes them and each invocation credits its baseline — if that proves too generous, the honest fix is the catalogue value or a dedupe window, not a silent cap. (v0.65.0, 2026-08-07; v0.69.0, 2026-08-08)
Transport & hosting contract
Section titled “Transport & hosting contract”The API contract now includes where the API lives, because the callable origin changed (2026-07).
- The backend runs on Google Cloud Run — service
moltrace-backend, projectmoltrace-prod, regionus-central1, scale-to-zero. Render is fully retired; any client, runbook, or webhook still pointing at a Render URL is wrong. - The frontend stays on Vercel (moltrace.co) and reaches the API through a same-origin
/api/backendproxy. Browser clients target the proxy path, not the Cloud Run origin. - Cloud SQL for PostgreSQL 16 sits on a private IP with no public interface, reached over Direct VPC egress. There is no publicly routable database endpoint to configure.
- Cloud Storage holds the immutable raw-FID vault (
moltrace-raw-vault, versioned), model weights, and the DVC remote. Secret Manager holds every credential, Cloud KMS holds the field-encryption key, and images build through Cloud Build into Artifact Registry. - CI/CD deploys the backend keylessly via Workload Identity Federation — no stored service-account key — behind the existing fail-closed release gate, so an unverified build still cannot reach production.
Raw-vault storage backend (GCS)
Section titled “Raw-vault storage backend (GCS)”Cloud Run’s filesystem is ephemeral, so the write-once ALCOA+ raw-FID vault gained a GCS storage backend (2026-07). It preserves the same guarantees the local backend gives:
- Writes are create-only, enforced with an
if_generation_match=0precondition — an existing object can never be overwritten. - SHA-256 verification runs both on reuse of an existing object and immediately after a write.
- Bucket retention plus versioning is the WORM mechanism.
- Selected with
RAW_VAULT_BACKEND=gcsandRAW_VAULT_BUCKET. The local filesystem backend remains the default, so nothing changes for self-hosted or local deployments.
Releases with no API surface
Section titled “Releases with no API surface”Several releases in the v0.53.0–v0.61.0 range shipped CI, library, or documentation work only and add no endpoint, request shape, or response field. They are listed here so a contract reviewer does not go looking:
- v0.53.0 (2026-06-20) — secure-SDLC CI gates (SAST / SCA / IaC, CRITICAL-blocking). CI/repo config only.
- v0.54.0 (2026-06-21) — CycloneDX SBOM per build, SLSA build provenance signed keylessly via Sigstore, and a verify-at-deploy gate that blocks every deploy hook on a verification failure. CI only, no application code.
- v0.57.0 (2026-06-26) — IaC posture scoring with a drift gate, plus every GitHub Action
uses:pinned to a 40-char commit SHA and a least-privilege defaultpermissions: { contents: read }. CI/IaC/docs only. - v0.59.0 (2026-06-26) — the incident-response notification-deadline engine (
src/nmrcheck/ir_timeline.py) is library-only, deliberately not wired intoapi.py. It computes timing, not legal conclusions, and sends nothing. - v0.60.0 (2026-06-26) — the restore-integrity verifier (
src/nmrcheck/dr_verify.py) is a library plus CLI, noapi.pyroute:python -m nmrcheck.dr_verify --min-rows audit_events=1,users=1exits0verified /1failed /2cannot-connect. Operators call it after a restore; it is not an endpoint. - v0.61.0 (2026-06-29) — the control→evidence register (
compliance/controls.json+validate_controls.py) is a repo-root, fail-on-drift CLI check plus the compliance-map, Trust Center, and sub-processor content. No application or runtime code. SOC 2 and ISO/IEC 27001:2022 are audited certifications MolTrace does not hold — the register and its docs are framed “designed to support / pursuing”, never “compliant” or “certified”, and any surface that renders this material must preserve that framing.
Frontend regeneration cadence
Section titled “Frontend regeneration cadence”The FE↔BE contract is openapi.json → npm run generate:openapi → moltrace_frontend/src/lib/api/schema.d.ts. Regenerate the schema after any release that adds a new endpoint or changes an existing request/response shape (every release flagged “Contract change — frontend must regenerate schema.d.ts” in the upstream moltrace_backend/CHANGELOG.md).
In the v0.64.0–v0.78.0 range the regeneration triggers are v0.68.9 (the FID process result — smiles becomes optional and the analysis block becomes nullable; the DP4 and compound-registry changes in the same release need none), v0.69.8–v0.69.9 (the FIDPresetId enum, GET /fid/presets, and the unknown_processing_preset code), v0.76.0 (the shared 401/403 components/responses pair and the five new public codes — PUBLIC_CODES 9 → 14), v0.77.0 (POST /desktop/entitlement-statements, GET /desktop/entitlement-authority) and v0.78.0 (GET /system/active-versions). The knowledge-corpus routes (v0.68.4, v0.68.10) and the /ml/deployment-candidates/{id}/approve promotion block (v0.67.1) add models and also require a regeneration. v0.69.2 deliberately does not: its new refusal text rides in a field already typed as a free-form object, so the schema does not change.
Two releases in this range are notable for what they did not change. v0.75.0 (2026-08-21, additional WebAuthn origins) touches no route, request or response model, so there is no contract delta at all. v0.75.3 adds fields to an existing residual-solvent summary that two downstream consumers already copy verbatim — neither consumer needed changing, which is why the fix sits at the producer, and the new keys were verified to survive the CTD bundle’s public-field filter rather than assumed to.
In the earlier v0.53.0–v0.61.0 range the regeneration triggers are v0.56.0 (the /.well-known/security.txt route joins the public allow-list) and v0.58.0 (the two /admin/security/… routes plus the SecurityAlert / DetectionScanResult / DetectionId models and the two new SecurityEventType members; that release ships its own frontend handoff note). The remaining releases in the range add no schema.