diff --git a/.github/workflows/operations-monitor.yml b/.github/workflows/operations-monitor.yml
new file mode 100644
index 0000000..c9c90c6
--- /dev/null
+++ b/.github/workflows/operations-monitor.yml
@@ -0,0 +1,39 @@
+name: Operations Monitor
+on:
+ schedule:
+ # Four checks a day; avoid keeping free services continuously awake.
+ - cron: '15 */6 * * *'
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: operations-monitor
+ cancel-in-progress: false
+jobs:
+ observe:
+ name: Observe ${{ matrix.environment }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 7
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - environment: production
+ api: https://chainlens-4qvy.onrender.com
+ indexer: disabled
+ - environment: testing
+ api: https://veritras-api-testing.onrender.com
+ indexer: enabled
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Check sustained readiness, API errors and SSE heartbeat
+ env:
+ API: ${{ matrix.api }}
+ ENVIRONMENT: ${{ matrix.environment }}
+ INDEXER: ${{ matrix.indexer }}
+ run: python backend/monitor_operations.py --url "$API" --environment "$ENVIRONMENT" --expect-indexer "$INDEXER"
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 9d9c6d8..13d9710 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -1,6 +1,6 @@
# Veritras implementation plan
-Originally prepared October 17, 2026 from [PROJECT_REVIEW.md](PROJECT_REVIEW.md). **Status updated October 19, 2026 for increment 9.** Integration recovery checks, typed contracts, protected-main merge controls and production admission gates are implemented. Final CI/deployment evidence is recorded in the increment 7 pull request; see [release controls](RELEASE_CONTROLS.md) and the [release/recovery runbook](RELEASE_RUNBOOK.md).
+Originally prepared October 17, 2026 from [PROJECT_REVIEW.md](PROJECT_REVIEW.md). **Status updated October 20, 2026 for increment 10.** Integration recovery checks, typed contracts, protected-main merge controls and production admission gates are implemented. Final CI/deployment evidence is recorded in the increment 7 pull request; see [release controls](RELEASE_CONTROLS.md) and the [release/recovery runbook](RELEASE_RUNBOOK.md).
## Status at a glance
@@ -22,7 +22,7 @@ Priority meanings: **P0 — release blocker** must be resolved before enabling p
| Integration and release controls (increment 7) | **Done** | **P1 addressed** | Real write-failure/restart, process-loss, reorg and backup/restore rehearsals; typed detail/verification; protected main and exact-commit production admission; see [runbook](RELEASE_RUNBOOK.md) |
| QR verification, shipment detail and guided demo (increment 8) | **Done** | **P2 addressed** | Direct public links and SVG QR labels, canonical paginated timeline, shipment detail with contract-checked transitions, isolated guided demo, mobile wallet/keyboard access and route metadata; see [guide](PRODUCT_JOURNEY.md) |
| Shipment exceptions (increment 9) | **Done** | **P2 addressed** | Configurable owner policy, canonical arrival/checkpoint/custody rules, durable deduplicated occurrences, acknowledgement/history, synchronized evaluation and public mobile list; see [guide](SHIPMENT_EXCEPTIONS.md) |
-| Performance and operations (increment 10) | **Pending** | P2 | Existing lazy routes/Lighthouse checks provide a baseline; measured optimization and operational completion remain |
+| Performance and operations (increment 10) | **Done** | **P2 addressed** | Measured mobile LCP 11.33→5.37 s, deferred SDK/chart loading, private diagnostics, stream recovery, scheduled operations checks and migration-window safeguards; validation and release evidence in [PR #28](https://github.com/Oionos/Veritras/pull/28) |
| Business pilot integrations | **Deferred** | Later | Organizations, evidence files, imports, standards integrations and repeated journeys |
### Urgent safeguards and next actions
@@ -33,6 +33,8 @@ Priority meanings: **P0 — release blocker** must be resolved before enabling p
4. **Maintain release enforcement.** Main reports protected and the owner confirmed all eight required checks, up-to-date PRs and no bypasses/force pushes/deletions. Production Docker and frontend builds require successful exact-main-commit CI; API startup validates its built admission proof without GitHub access; frontend also waits for a compatible ready API. API rule-detail readback remains forbidden to this integration. See [RELEASE_CONTROLS.md](RELEASE_CONTROLS.md).
5. **Preserve testing isolation and free-tier limits.** Use the stable `testing` preview for full-stack checks. Production and testing Render services share the workspace's 750 free instance hours/month; avoid continuous keep-alive pings. See [HOSTED_TESTING.md](HOSTED_TESTING.md).
+6. **Maintain operations and collect field evidence.** Increment 10 adds private diagnostics, mobile budgets and four scheduled checks per day. Field percentiles and personal failure-notification delivery remain unvalidated; see [operations](OPERATIONS.md) and [measurements](PERFORMANCE.md). Use the coordinated migration preflight for future DDL; never migrate production while exact-main CI is pending.
+
### Completed releases and evidence
| Release | Main commit | Completed work |
@@ -85,9 +87,9 @@ The numbers below are **plan increment IDs**, not actual GitHub pull request num
| 5 | **Done / P1 addressed** | `codex/registration-lifecycle-evidence` | Maintain durable transaction recovery, checked evidence and metadata history; release tracked in PR #21 | 2–4 |
| 6 | **Done / P1 addressed** | `codex/truthful-analytics-and-exports` | Maintain canonical cohorts, SLA exclusions and safe matching exports; release tracked in PR #22 | 3–5 delivered |
| 7 | **Done / P1 addressed** | `codex/integration-release-controls` | Maintain real recovery drills, protected-main checks and production admission; see [runbook](RELEASE_RUNBOOK.md) | 2–6 delivered |
-| 8 | Pending / P2 | `feat/qr-verification-and-demo` | QR links, unified shipment detail, mobile and guided demo | 5 and 7 |
-| 9 | Pending / P2 | `feat/shipment-exceptions` | Expected arrival, overdue and dwell exception list | 6–8 |
-| 10 | Pending / P2 | `perf/frontend-and-operations` | Measured loading improvements and operational cleanup | Stable reliable-demo milestone |
+| 8 | **Done / P2 addressed** | Released in #25 | Maintain QR links, shipment detail, mobile and guided demo; see [guide](PRODUCT_JOURNEY.md) | 5 and 7 |
+| 9 | **Done / P2 addressed** | Released in #27 | Maintain canonical exception rules, history and acknowledgement; see [guide](SHIPMENT_EXCEPTIONS.md) | 6–8 |
+| 10 | **Done / P2 addressed** | `codex/performance-and-operations`, PR #28 | Maintain loading budgets, private diagnostics, periodic health checks and recovery procedures; field comparison awaits real traffic | 5–9 delivered |
Tests and migration checks belong in each implementation PR. PR 7 assembles the complete flow and release gates; it is not the first testing work.
@@ -296,19 +298,19 @@ Completed in increment 7: actual PostgreSQL checkpoint failure after a metadata
### PR 10: Performance and operations
-**Status: pending completion. Priority: P2.** Existing lazy routes, error reporting configuration and Lighthouse CI are a baseline. Measured optimization, environment-aware observability and readiness alerts remain. Increment 7 supplies the local backup/restore rehearsal and recovery runbook; hosted restore acceptance belongs to the separate production enablement gate.
+**Status: done for implementation and acceptance scope. Priority: P2 addressed.** Three controlled mobile samples per build show median LCP 11.33→5.37 seconds, transfer −60%, initial gzip JavaScript −69%, with unchanged CLS. See [performance evidence](PERFORMANCE.md) and [operations](OPERATIONS.md). Private error/route telemetry, replay disabled, API/SSE counters, heartbeat recovery, scheduled cold-start-aware monitoring and coordinated migration preflight are implemented. Valid production field percentiles require real traffic; no historical field comparison is claimed. Local backup/restore is rehearsed; hosted production restore acceptance remains in the separate enablement gate.
- Measure mobile loading and interaction before changing bundles; investigate wallet/provider startup, chart imports, and unnecessary SDK loading.
- Defer optional wallet/chart code where measurements justify it. Compare before/after Lighthouse and field metrics using the same scenario.
- Configure release/environment labels for error tracking, appropriate replay masking, and monitoring for indexer lag, API failures, and event-stream health.
- Document backup/restore and single-indexer ownership. Split the indexer into a worker only if uptime/scaling requirements justify the additional service.
-**Acceptance:** performance improvements have measured evidence; error reporting excludes sensitive input; indexing and API degradation are actionable and observable.
+**Acceptance completed:** three-run before/after mobile evidence; regression-tested privacy allowlists and replay disabled; real wallet/reconnect and recovery/restore journeys; hosted testing mobile/bundle/privacy/SSE checks; sustained testing readiness with zero lag and an actual heartbeat. Exact-main CI, provider IDs and final production checks are retained in [PR #28](https://github.com/Oionos/Veritras/pull/28). Ongoing field comparison requires real production samples; native notification delivery and hosted production restore/backfill are explicit separate limits.
## Migration, backfill, and release procedure
1. Inspect production deployment/schema state and record a restorable database checkpoint. Rehearse on an isolated database copy first.
-2. Apply additive migrations and deploy compatible backend code. Keep any old cursor/projection writer paused while reconciling identities or rebuilding event projections.
+2. After protected merge, exact-main CI and candidate image admission, confirm the prior API is already awake/ready and run the short-lived migration-window preflight. Coordinate additive DDL with immediate compatible runtime rollout; never apply production DDL while CI is pending. See [operations](OPERATIONS.md#coordinated-migration-window). Keep any old cursor/projection writer paused while reconciling identities or rebuilding event projections.
3. Run reconciliation in dry-run mode, inspect conflicts, then apply validated changes. Add uniqueness/non-null constraints only after conflicting data is resolved.
4. Load/replay confirmed canonical logs in bounded ranges from each configured deployment block, validating receipt identities and projection counts. Persist progress transactionally.
5. Confirm readiness, indexing progress, permissions, verification results, and analytics. Deploy the compatible frontend against the intended environment.
@@ -330,7 +332,7 @@ After the reliable demo is complete and a specific user workflow has been valida
## Next implementation session
-The next product increment is **9: shipment exception list**. Increments 5–8 are implemented, including truthful analytics, enforced release controls, public QR verification and the guided/mobile journey. **Production indexing enablement validation remains important and pending:** review the deployment anchor, rehearse identity/history reconciliation and hosted restore, then obtain explicit enablement before backfill. Keep that gate separate from ordinary application releases.
+Increment **10: performance and operations** is delivered; final protected-main and provider evidence is recorded in [PR #28](https://github.com/Oionos/Veritras/pull/28). Increments 5–9 are delivered. After increment 10, prioritize field observation and a defined business pilot; there is no automatically authorized production backfill. **Production indexing enablement validation remains important and pending:** review the deployment anchor, rehearse identity/history reconciliation and hosted restore, then obtain explicit enablement before backfill. Keep that gate separate from ordinary application releases.
Preserve analytics definitions and sample/exclusion counts during subsequent work. Keep production indexing enablement and any change to release-gate configuration as explicit reviewed changes.
diff --git a/OPERATIONS.md b/OPERATIONS.md
new file mode 100644
index 0000000..cbc413d
--- /dev/null
+++ b/OPERATIONS.md
@@ -0,0 +1,51 @@
+# Operations
+
+## Signals and privacy
+
+`GET /health/live` is process liveness and remains the Render health path. `GET /health/ready` returns 503 for unavailable database or degraded indexing, with actionable bounded reasons, confirmed-target lag and freshness. Production deliberately expects indexing **disabled**; testing expects an enabled, current indexer. A live process is not proof of current chain projections.
+
+`GET /health/operations` returns readiness plus bounded process-local counters: the last 15 minute buckets of requests/business API 5xx, average header-response latency and cumulative ≤100/500/2000 ms buckets; stream active/open/closed/failure/resync/overflow totals; and bounded browser metric/route aggregates. SSE request duration measures response headers, not stream lifetime. Counters reset on restart, are per process, and are not a durable audit, distributed rate or percentile. No database migration, paid metrics service or extra worker is introduced.
+
+Structured application request logs contain environment, full release SHA when Render supplies it, generated request ID, method, route template, status, header duration and exception class. Query strings, concrete product/shipment IDs, IPs, tokens and request bodies are excluded. An unmatched route is labeled `unmatched`; some router versions report the leaf template. Arbitrary logging extras and exception messages are omitted. The request ID is returned as `X-Request-ID` for incident correlation. Review new logging call sites: do not put user input in the message itself.
+
+Browser telemetry accepts only enumerated metric and route names, finite bounded numbers, environment and validated release SHA. It rejects extra fields, never uses auth headers or cookies, is limited to 60 requests/minute per client, and deduplicates each metric/route for 30 seconds in the client. Demo sends no business/telemetry API calls. Browser counts are untrusted and do not trigger operational alerts. No input text, wallet address, signature, bearer token, product ID, shipment ID, query string or arbitrary error message is sent. No browser telemetry is persisted to Postgres.
+
+Vercel Analytics and Speed Insights strip URL parameters/fragments and replace dynamic IDs with route templates. Optional Sentry loads only when `VITE_SENTRY_DSN` is configured; events have release/environment, route, generic exception type and same-origin built asset frame locations. Requests, users, breadcrumbs, arbitrary context and exception text are dropped. Tracing and **all session replay are disabled**, so there is no recording of forms, wallet prompts, notes or DOM input. External Sentry delivery is not claimed when a DSN is absent. The generic browser error counter remains available without Sentry.
+
+## Scheduled checks
+
+[Operations Monitor](.github/workflows/operations-monitor.yml) runs four times a day at 00:15, 06:15, 12:15 and 18:15 UTC (08:15, 14:15, 20:15 and 02:15 Asia/Manila), and supports manual dispatch. It checks both APIs with a four-minute cold-start grace and requires three successive readiness samples, then checks aggregate API errors and a real SSE heartbeat. The stream client checks named heartbeats, reconnects with bounded backoff, pauses while hidden and displays a disconnected notice. A replay/resync refreshes committed queries after recovery.
+
+The workflow exits nonzero with named conditions and runbook actions for sustained readiness failure, three or more business API 5xx in the retained 15-minute window, malformed diagnostics, or missing SSE heartbeat. Existing readiness checks remain separate. Failures appear in GitHub Actions and its native notification settings; no new email, Slack, webhook or external recipient is configured. Owners can enable GitHub Actions failure notifications in their own GitHub notification preferences. Notification delivery was not tested.
+
+These checks are periodic, not a real-time paging SLA. Free-service cold starts are allowed; no continuous keep-alive is used. Errors between samples, on an earlier process, or outside the retained window can be missed. For a paid pilot with an uptime requirement, first define retention, on-call recipient and escalation preferences, then add durable metrics and an appropriate monitor through a reviewed change.
+
+## Incident response
+
+| Condition | Inspect | Recovery acceptance |
+| --- | --- | --- |
+| `readiness` | `/health/ready`: database, indexer reason, confirmed-target lag and successful poll age; Render logs for the exact release | Three ready samples; testing indexer current/fresh, or production disabled as configured. Investigate RPC/database/scope before restarting. Never advance a cursor to hide lag. |
+| `api_errors` | Request `request_id`, route template, status, exception class and release; deployment and DB availability | Fix cause through protected CI; verify business endpoint behavior and monitor a fresh error window. Browser reports are diagnostic, not authorization to mutate data. |
+| `event_stream` | `event_stream_unavailable`, stream failures/overflows, database connection and proxy behavior | Actual named heartbeat, browser reconnect/resync, and committed data refresh. Do not treat reconnect alone as proof of indexer health. |
+| `diagnostics` | Exact commit, schema admission, environment and operations response | Typed, correct-environment diagnostics and ready API. No fabricated counters or gate bypass. |
+| Schema/cold-start failure | Candidate exact-main CI, image admission, current schema and release | Follow coordinated migration window below. Do not restart an incompatible historical image against the new schema. |
+
+Avoid logging error payloads or sharing unreviewed HAR files, wallet storage, screenshots of secret settings, or temporary preview access links. Preserve safe request IDs, commit and provider deployment IDs in the release record.
+
+## Backup, restore and indexer ownership
+
+Follow [RELEASE_RUNBOOK.md](RELEASE_RUNBOOK.md) and [DURABLE_INDEXING.md](DURABLE_INDEXING.md). Before DDL, retain a restorable database checkpoint and rehearse on an isolated clone. The local rehearsal acquires the writer lock, dumps/restores and compares schema plus full business/journal/cursor/history fingerprints; its actual result is required alongside unit tests. Hosted production restore acceptance and production backfill remain a separate enablement gate. Do not copy production data into a public preview.
+
+Use one intended indexing owner per chain/registry/tracker scope. The PostgreSQL advisory writer lock and transactional cursor are authoritative; another process must report writer contention and retry rather than writing concurrently. Never delete the lock/cursor or import the obsolete file cursor. A restart must resume the last committed batch once; chain reorganizations rewind/replay transactionally. Production remains disabled until reconciliation, deployment anchor, isolated hosted restore and explicit enablement approval are complete.
+
+A separate paid worker is not justified by the current single-instance free-service demo. If uptime or horizontal API scaling becomes required, design a reviewed worker deployment with one intended owner, the same DB lock, explicit health/lag monitoring and tested shutdown/recovery. Do not enable multiple writers as a scaling shortcut.
+
+## Coordinated migration window
+
+Increment 10 has **no DDL**; schema remains `e93a107bd624`. Increment 9 exposed a restart window when DDL was applied before main CI completed while the old free API was asleep. The old artifact correctly refused the newer schema; the replacement later recovered without data loss. Future strict-head migrations must use this sequence:
+
+1. Rehearse additive SQL on an isolated restore, retain a backup and record current/candidate schema compatibility. Stage and test first. Do not apply production DDL while feature/main CI is pending.
+2. Merge through the protected PR, wait for every required exact-main push check, and inspect the candidate Render image build admission. Confirm the prior API is already awake and ready on its expected SHA. A cold, incompatible old image cannot be the handoff safety net.
+3. Run `backend/check_migration_window.py --candidate FULL_MAIN_SHA --current FULL_SERVING_API_SHA --url https://chainlens-4qvy.onrender.com --sql-file REVIEWED_SQL_PATH`. It checks exact-main admission first, then current liveness/readiness/identity, and hashes reviewed SQL. Its two-minute evidence is read-only and is not authorization to execute DDL. Recheck if delayed.
+4. Confirm the admitted replacement is ready for immediate rollout; coordinate reviewed DDL and runtime handoff in the same window. If the provider cannot guarantee the handoff, use an explicitly scheduled maintenance window or a separately reviewed compatibility release. Do not apply early and hope CI finishes. An awake old process reduces the cold-start risk but cannot guarantee uninterrupted service if it crashes.
+5. Verify new exact release, schema, `/health/ready`, frontend attestation and unchanged business fingerprints. Retain evidence. Use corrective migrations or a rehearsed isolated restore; never weaken schema admission or run destructive downgrades.
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
new file mode 100644
index 0000000..40c2580
--- /dev/null
+++ b/PERFORMANCE.md
@@ -0,0 +1,33 @@
+# Performance measurements
+
+Increment 10 measured the anonymous dashboard before changing loading boundaries. The measured frontend source is feature commit `25819f84866787e741ab4dcd039313ac094b451a`; subsequent changes harden URL allowlisting and monitoring without changing loading boundaries. The baseline is main `7adb990863aa399d2dd323d80e1bfa090794716f`. Results and individual sample values are retained in [performance/increment10.json](performance/increment10.json).
+
+## Controlled mobile comparison
+
+Measured October 20, 2026 (Asia/Manila), Chromium 154.0.8037.57. Three cold-cache Lighthouse runs per build, same frozen API fixtures, gzip server, 390×844 viewport and Lighthouse simulated mobile CPU/network. External HTTPS was blocked equally in both builds. API latency, production CDN behavior, WalletConnect provider latency and real user devices are outside this comparison.
+
+| Median | Before | After | Change |
+| --- | ---: | ---: | ---: |
+| Lighthouse performance | 47 | 61 | +14 points |
+| First contentful paint | 6.50 s | 3.52 s | 46% lower |
+| Largest contentful paint | 11.33 s | 5.37 s | 53% lower |
+| Total blocking time | 463 ms | 448 ms | 3% lower |
+| Cumulative layout shift | 0 | 0 | unchanged |
+| Total transferred bytes | 1,671,523 | 660,866 | 60% lower |
+| Initial JavaScript, gzip | 1,226,486 | 383,058 | 69% lower |
+
+One supplementary menu interaction took 146 ms before and 106 ms after. It includes Playwright click overhead and two animation frames: **it is not field INP or a percentile**, and is not the basis of the improvement claim. LCP remains above the 2.5-second good threshold in this constrained lab.
+
+A repeat on the final build retained the loading/transfer gains; blocking time varied from the initial trial, so the table uses the final measurements. The previous manual Web3 group combined optional SDKs with eager imports. Removing it alone reduced transfer but worsened blocking time in an intermediate trial. The final change also defers remote connector setup until wallet intent, restores it immediately for stored wallet connections, defers the below-fold chart by visibility, and schedules optional diagnostic SDK loading during idle time. No dependency upgrade or weaker Lighthouse threshold was needed.
+
+## Reproduce and prevent regression
+
+From `frontend`, build with `CI=true VITE_API_URL=http://127.0.0.1:18010 VITE_WALLETCONNECT_PROJECT_ID=ci-test-project-id npm exec -- vite build --manifest`, then run `node scripts/measure-performance.mjs dist ../.testing/performance-comparison`. Use Node 24 for direct fixture TypeScript loading and set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` if Chromium is elsewhere. Preserve the baseline artifact, use the same browser and machine, and avoid competing workloads. The output contains three Lighthouse JSON reports, a summary and mobile screenshot; keep them local unless reviewed for publication.
+
+The existing Lighthouse CI checks remain required. The Playwright startup regression additionally caps anonymous dashboard script bytes at **450,000 gzip bytes**, including idle diagnostics; verifies no chart or remote WalletConnect/Reown SDK starts before intent/visibility; scrolls the chart into view and opens the wallet picker. Real local integration checks cover signing, reconnect, switching wallets and contract writes.
+
+## Field measurements
+
+No valid production before/after field comparison was available for this release. The connected metrics query could not retrieve a valid dataset, and Vercel documents programmatic metric queries as requiring Observability Plus. No plan upgrade was requested or made. Lab measurements above must not be presented as production percentiles.
+
+Vercel Speed Insights remains enabled after idle loading and receives sanitized route URLs. Compare production mobile p75 LCP and INP over equal seven-day windows only after both windows have meaningful counts; record dates, release, device mix, route and count alongside each value. Do not combine preview/demo traffic with production or compare a single synthetic visit to field traffic. The public API diagnostics expose process-local browser LCP/CLS means and maximum-interaction samples; these are untrusted diagnostic samples, not durable RUM percentiles or field INP. See [operations](OPERATIONS.md) for retention and privacy.
diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md
index ab522ff..d6b61a5 100644
--- a/RELEASE_RUNBOOK.md
+++ b/RELEASE_RUNBOOK.md
@@ -1,14 +1,14 @@
# Release, backfill and recovery
-This runbook accompanies [release controls](RELEASE_CONTROLS.md), [durable indexing](DURABLE_INDEXING.md), [canonical identity](CANONICAL_PRODUCT_IDENTITY.md), [health](INDEXER_HEALTH.md) and [analytics](ANALYTICS.md). Increment 7 adds no database migration: the current head is `c62a904df318`. **Ordinary releases keep production `INDEXER_ENABLED=false`.** Enabling or reconstructing production indexing requires the separate reviewed procedure below.
+This runbook accompanies [release controls](RELEASE_CONTROLS.md), [durable indexing](DURABLE_INDEXING.md), [canonical identity](CANONICAL_PRODUCT_IDENTITY.md), [health](INDEXER_HEALTH.md) and [analytics](ANALYTICS.md). Increment 10 adds no database migration: the current head remains `e93a107bd624`. See [operations](OPERATIONS.md) and [measured performance](PERFORMANCE.md). **Ordinary releases keep production `INDEXER_ENABLED=false`.** Enabling or reconstructing production indexing requires the separate reviewed procedure below.
## Release order and evidence
1. Start from current main on a feature branch. Keep API/schema changes additive and compatible with the running frontend. Generate OpenAPI from the actual application and regenerate `frontend/src/types/api.ts`; the drift check must pass. Never copy production secrets into CI or preview configuration.
2. Run the serial local acceptance sequence below, then stage the feature into the persistent `testing` branch with an ordinary merge. Use the stable testing Vercel alias so CORS and SIWE origins match. Verify the deployed testing commit, schema, separate database, chain 11155111, registry/tracker and deployment anchor against [HOSTED_TESTING.md](HOSTED_TESTING.md). Testing uses its own Sepolia contracts and wallets, with 12 confirmations; local Hardhat uses chain 31337 and zero confirmations. Sepolia ETH comes from a free faucet.
-3. Record a restorable database checkpoint before any future migration. Rehearse that migration on an isolated copy and compare business/journal counts and identities. Review generated SQL and ownership changes. Run `python manage_schema.py` read-only first, then `python manage_schema.py --apply` only against the explicitly selected database. The deployment entrypoint rejects an outdated schema; it never migrates production automatically. Stop for identity/history conflicts. Increment 7 needs no schema change.
+3. Record a restorable database checkpoint before any future migration. Rehearse that migration on an isolated copy and compare business/journal counts and identities. Review generated SQL and ownership changes. Run `python manage_schema.py` read-only first. Retain the rehearsal and reviewed SQL; defer production `--apply` until the coordinated window after exact-main CI and image admission in step 5. The deployment entrypoint rejects an outdated schema; it never migrates production automatically. Stop for identity/history conflicts. Increment 10 needs no schema change.
4. Open an up-to-date PR targeting protected main. All eight named GitHub Actions checks in [RELEASE_CONTROLS.md](RELEASE_CONTROLS.md) must succeed. Record the feature SHA, testing deployment SHA, check run URLs and environment. Skipped, cancelled, neutral and missing jobs do not meet production admission. Do not bypass the rule or weaken a failing check.
-5. Squash merge the green PR. The new main SHA gets its own push CI and integration runs. Render starts a Docker build on commit; the Dockerfile waits for those exact main-push runs and records a commit/policy/workflow admission proof in the image. `start_api.py` validates that proof locally, then checks schema and binds the port. Cold starts of an admitted image make no GitHub request. Its platform health path is `/health/live`. The previous running service remains available during replacement. Production readiness requires its database and the disabled-indexer policy. Inspect `/health/live` and `/health/ready`, including `release_commit`, environment and chain.
+5. Squash merge the green PR. The new main SHA gets its own push CI and integration runs. Render starts a Docker build on commit; the Dockerfile waits for those exact main-push runs and records a commit/policy/workflow admission proof in the image. `start_api.py` validates that proof locally, then checks schema and binds the port. Cold starts of an admitted image make no GitHub request. Its platform health path is `/health/live`. For a schema-changing release, first complete exact-main CI, confirm candidate image admission, and verify the old API is already awake and ready. Run `check_migration_window.py` and coordinate reviewed DDL with immediate rollout as described in [operations](OPERATIONS.md#coordinated-migration-window). An old artifact cannot cold-start against an unrecognized schema head; never migrate while CI is pending. An awake process reduces that restart risk but does not guarantee availability if it crashes. For a release without DDL the existing service follows the normal replacement path. Production readiness requires its database and the disabled-indexer policy. Inspect `/health/live` and `/health/ready`, including `release_commit`, environment and chain.
6. Vercel's production build waits for the same exact main-push checks and then a ready compatible production API before building. For a frontend-only release, the API may use the latest backend-changing ancestor commit. `/release.json` records the frontend SHA, checked workflow IDs/attempts and observed backend SHA; it contains no credentials. Inspect that attestation and the real frontend's API origin after publication.
7. Verify public product detail/verification, dashboard/analytics, filtered CSV/PDF exports, and a narrow viewport. Confirm production counts did not change from testing activity and production indexing remains disabled. Keep the final main SHA, provider deployment IDs and results in the PR/release record. Avoid continuous keep-alive polling on free Render services.
diff --git a/backend/app/api/routes/analytics.py b/backend/app/api/routes/analytics.py
index 04c12a1..5697f64 100644
--- a/backend/app/api/routes/analytics.py
+++ b/backend/app/api/routes/analytics.py
@@ -121,19 +121,31 @@ async def analytics_events():
queue = broadcaster.subscribe()
async def event_generator():
+ from app.services.operations import operations
+ import logging
+
try:
revision = await asyncio.to_thread(chain_revision)
+ operations.stream("resyncs")
yield "event: resync\ndata: {}\n\n"
while True:
try:
message = await asyncio.wait_for(queue.get(), timeout=5)
yield message
except asyncio.TimeoutError:
- yield ": heartbeat\n\n"
+ yield ": heartbeat\nevent: heartbeat\ndata: {}\n\n"
latest = await asyncio.to_thread(chain_revision)
if latest != revision:
revision = latest
yield "event: chain_updated\ndata: {}\n\n"
+ except asyncio.CancelledError:
+ raise
+ except Exception as error:
+ operations.stream("failures")
+ logging.getLogger("veritras.events").error(
+ "event_stream_unavailable", extra={"error_type": type(error).__name__}
+ )
+ yield "event: unavailable\ndata: {}\n\n"
finally:
broadcaster.unsubscribe(queue)
diff --git a/backend/app/api/routes/telemetry.py b/backend/app/api/routes/telemetry.py
new file mode 100644
index 0000000..ed9d1e4
--- /dev/null
+++ b/backend/app/api/routes/telemetry.py
@@ -0,0 +1,16 @@
+from fastapi import APIRouter, HTTPException, Request, Response
+from app.core.config import settings
+from app.core.limiter import limiter
+from app.schemas.operations import BrowserMetric
+from app.services.operations import operations
+
+router = APIRouter()
+
+
+@router.post("/browser", status_code=204)
+@limiter.limit("60/minute")
+def browser_metric(request: Request, metric: BrowserMetric):
+ if metric.environment != settings.environment:
+ raise HTTPException(422, "Telemetry environment does not match this API")
+ operations.browser_metric(metric.metric, metric.route, metric.value)
+ return Response(status_code=204)
diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py
index 6efcd66..0e4ff7f 100644
--- a/backend/app/core/logging.py
+++ b/backend/app/core/logging.py
@@ -2,21 +2,14 @@
import logging
import os
import sys
-
-# Fields present on every LogRecord by default — skip when merging extras.
-_RESERVED = frozenset({
- "name", "msg", "args", "levelname", "levelno", "pathname", "filename",
- "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
- "created", "msecs", "relativeCreated", "thread", "threadName",
- "processName", "process", "message", "taskName",
-})
+import re
class JsonFormatter(logging.Formatter):
"""Emit one JSON object per log record to stdout.
- Any key passed via extra={} lands as a top-level JSON field, making log
- lines queryable by key in Render, Datadog, Loki, etc.
+ Only operational fields from an explicit allowlist become JSON fields.
+ Exception messages and arbitrary extras are excluded.
"""
def format(self, record: logging.LogRecord) -> str:
@@ -27,14 +20,36 @@ def format(self, record: logging.LogRecord) -> str:
"level": record.levelname,
"logger": record.name,
"message": record.message,
+ "environment": os.environ.get("ENVIRONMENT", "development"),
+ "release": (
+ os.environ.get("RENDER_GIT_COMMIT")
+ if re.fullmatch(
+ r"[0-9a-f]{40}", os.environ.get("RENDER_GIT_COMMIT", "")
+ )
+ else None
+ ),
}
for key, val in record.__dict__.items():
- if key not in _RESERVED and not key.startswith("_"):
+ if key in {
+ "method",
+ "path",
+ "status",
+ "duration_ms",
+ "request_id",
+ "error_type",
+ "reason",
+ "last_processed_block",
+ "target_head",
+ "lag_blocks",
+ "last_error",
+ }:
log_obj[key] = val
if record.exc_info:
- log_obj["exc_info"] = self.formatException(record.exc_info)
+ log_obj["exc_info"] = (
+ type(record.exc_info[1]).__name__ + " (details omitted)"
+ )
return json.dumps(log_obj, default=str)
diff --git a/backend/app/main.py b/backend/app/main.py
index 6834b5b..2b7564a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -3,6 +3,7 @@
import re
import logging.config
import time
+import uuid
from contextlib import asynccontextmanager
from typing import Optional
@@ -20,7 +21,9 @@
from app.core.limiter import limiter
from app.core.logging import LOGGING_CONFIG
from app.core.security import SecurityHeadersMiddleware
-from app.api.routes import auth, products, shipments, analytics, exceptions
+from app.api.routes import auth, products, shipments, analytics, exceptions, telemetry
+from app.schemas.operations import OperationsResponse
+from app.services.operations import operations
from app.services.exceptions import ExceptionWorker
from app.services.indexer import EventIndexer
from app.services.health import DatabaseProbe
@@ -143,21 +146,43 @@ def parse_cors_origins(cors_origins: str) -> list[str]:
@app.middleware("http")
async def log_requests(request: Request, call_next):
- start_time = time.time()
- response = await call_next(request)
+ start_time = time.monotonic()
+ request_id = uuid.uuid4().hex
+ response = None
+ error_type = None
+ try:
+ response = await call_next(request)
+ except Exception as error:
+ error_type = type(error).__name__
+ raise
+ finally:
+ duration_ms = (time.monotonic() - start_time) * 1000
+ route = getattr(request.scope.get("route"), "path", "unmatched")
+ status = response.status_code if response is not None else 500
+ operations.request(
+ status=status,
+ duration_ms=duration_ms,
+ business=not route.startswith(("/health", "/telemetry")),
+ )
+ logger.log(
+ (
+ logging.ERROR
+ if status >= 500 and not route.startswith("/health")
+ else logging.INFO
+ ),
+ "request",
+ extra={
+ "method": request.method,
+ "path": route,
+ "status": status,
+ "duration_ms": round(duration_ms, 2),
+ "request_id": request_id,
+ "error_type": error_type,
+ },
+ )
+ response.headers["X-Request-ID"] = request_id
if request.url.path == "/health" or request.url.path.startswith("/health/"):
response.headers["Cache-Control"] = "no-store"
- duration_ms = (time.time() - start_time) * 1000
- logger.info(
- "request",
- extra={
- "method": request.method,
- "path": request.url.path,
- "status": response.status_code,
- "duration_ms": round(duration_ms, 2),
- "client_ip": request.client.host if request.client else None,
- },
- )
return response
@@ -166,6 +191,7 @@ async def log_requests(request: Request, call_next):
app.include_router(shipments.router, prefix="/shipments", tags=["shipments"])
app.include_router(analytics.router, prefix="/analytics", tags=["analytics"])
app.include_router(exceptions.router, prefix="/exceptions", tags=["exceptions"])
+app.include_router(telemetry.router, prefix="/telemetry", tags=["telemetry"])
def indexer_state():
@@ -249,6 +275,12 @@ def health_ready():
)
+@app.get("/health/operations", response_model=OperationsResponse)
+def health_operations():
+ """Bounded diagnostics; public aggregates contain no user input or identities."""
+ return {"health": readiness_state(), "counters": operations.snapshot()}
+
+
@app.get("/health")
def health_check():
"""Compatibility summary of readiness. Does not make a separate RPC call."""
diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py
new file mode 100644
index 0000000..6f84d82
--- /dev/null
+++ b/backend/app/schemas/operations.py
@@ -0,0 +1,58 @@
+from typing import Literal
+from pydantic import BaseModel, ConfigDict, Field
+from app.schemas.health import ReadinessResponse
+
+
+class BrowserMetric(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ metric: Literal[
+ "LCP",
+ "CLS",
+ "interaction_max_ms",
+ "client_error",
+ "api_error",
+ "stream_disconnected",
+ "stream_recovered",
+ ]
+ route: Literal[
+ "dashboard",
+ "products",
+ "shipments",
+ "shipment_detail",
+ "analytics",
+ "exceptions",
+ "verify",
+ "demo",
+ "privacy",
+ "terms",
+ "not_found",
+ ]
+ value: float = Field(ge=0, le=600000, allow_inf_nan=False)
+ release: str | None = Field(default=None, pattern=r"^[0-9a-f]{40}$")
+ environment: Literal["development", "testing", "production"]
+
+
+class BrowserAggregate(BaseModel):
+ metric: str
+ route: str
+ count: int
+ mean: float
+
+
+class OperationalCounters(BaseModel):
+ window_seconds: int
+ uptime_seconds: int
+ requests: int
+ api_requests: int
+ api_5xx: int
+ mean_duration_ms: float | None
+ duration_buckets: dict[str, int]
+ streams: dict[str, int]
+ browser_metrics: list[BrowserAggregate]
+ storage: Literal["process_memory"]
+ reset_on_restart: bool
+
+
+class OperationsResponse(BaseModel):
+ health: ReadinessResponse
+ counters: OperationalCounters
diff --git a/backend/app/services/events.py b/backend/app/services/events.py
index d688ef6..87a14b1 100644
--- a/backend/app/services/events.py
+++ b/backend/app/services/events.py
@@ -3,6 +3,7 @@
import asyncio
import json
import threading
+from app.services.operations import operations
class EventBroadcaster:
@@ -14,17 +15,22 @@ def subscribe(self):
queue = asyncio.Queue(maxsize=64)
with self._lock:
self._queues[queue] = asyncio.get_running_loop()
+ operations.stream("opened")
return queue
def unsubscribe(self, queue):
with self._lock:
- self._queues.pop(queue, None)
+ removed = self._queues.pop(queue, None)
+ if removed is not None:
+ operations.stream("closed")
@staticmethod
def _put(queue, message):
if queue.full():
+ operations.stream("overflows")
queue.get_nowait()
message = "event: resync\ndata: {}\n\n"
+ operations.stream("resyncs")
queue.put_nowait(message)
def publish_from_thread(self, event_type, data):
diff --git a/backend/app/services/operations.py b/backend/app/services/operations.py
new file mode 100644
index 0000000..1e90464
--- /dev/null
+++ b/backend/app/services/operations.py
@@ -0,0 +1,88 @@
+"""Bounded process-local operational counters. No identities, payloads or tokens."""
+
+from collections import Counter, deque
+import threading
+import time
+
+
+class Operations:
+ def __init__(self, clock=time.monotonic):
+ self.clock = clock
+ self.started = clock()
+ self.lock = threading.Lock()
+ self.requests = deque(maxlen=15)
+ self.browser = Counter()
+ self.streams = Counter(
+ active=0, opened=0, closed=0, failures=0, resyncs=0, overflows=0
+ )
+
+ def request(self, *, status, duration_ms, business=True):
+ with self.lock:
+ minute = int(self.clock() // 60)
+ if not self.requests or self.requests[-1][0] != minute:
+ self.requests.append((minute, Counter()))
+ counts = self.requests[-1][1]
+ counts["requests"] += 1
+ counts["duration_ms"] += max(0, duration_ms)
+ if business:
+ counts["api_requests"] += 1
+ counts["api_5xx"] += int(status >= 500)
+ for limit in (100, 500, 2000):
+ counts[f"le_{limit}_ms"] += int(duration_ms <= limit)
+
+ def stream(self, event):
+ assert event in {"opened", "closed", "failures", "resyncs", "overflows"}
+ with self.lock:
+ self.streams[event] += 1
+ if event == "opened":
+ self.streams["active"] += 1
+ if event == "closed":
+ self.streams["active"] = max(0, self.streams["active"] - 1)
+
+ def browser_metric(self, metric, route, value):
+ # Both metric/route come from bounded enums validated by the API schema.
+ with self.lock:
+ key = (metric, route)
+ self.browser[key] += 1
+ self.browser[(metric, route, "sum")] += value
+
+ def snapshot(self):
+ with self.lock:
+ minimum = int(self.clock() // 60) - 14
+ counts = Counter()
+ for minute, values in self.requests:
+ if minute >= minimum:
+ counts.update(values)
+ browser = [
+ dict(
+ metric=key[0],
+ route=key[1],
+ count=value,
+ mean=round(self.browser[(*key, "sum")] / value, 2),
+ )
+ for key, value in self.browser.items()
+ if len(key) == 2 and value
+ ]
+ return dict(
+ window_seconds=900,
+ uptime_seconds=int(self.clock() - self.started),
+ requests=int(counts["requests"]),
+ api_requests=int(counts["api_requests"]),
+ api_5xx=int(counts["api_5xx"]),
+ mean_duration_ms=(
+ round(counts["duration_ms"] / counts["requests"], 2)
+ if counts["requests"]
+ else None
+ ),
+ duration_buckets={
+ f"le_{limit}_ms": int(counts[f"le_{limit}_ms"])
+ for limit in (100, 500, 2000)
+ },
+ streams=dict(self.streams),
+ browser_metrics=browser,
+ storage="process_memory",
+ reset_on_restart=True,
+ )
+
+
+operations = Operations()
diff --git a/backend/check_migration_window.py b/backend/check_migration_window.py
new file mode 100644
index 0000000..114615f
--- /dev/null
+++ b/backend/check_migration_window.py
@@ -0,0 +1,96 @@
+"""Read-only prerequisite for production DDL; does not apply any migration.
+
+Run after protected merge and successful exact-main CI, while coordinating the
+replacement image rollout. Rehearsal and a retained backup remain mandatory.
+"""
+
+import argparse
+from datetime import datetime, timedelta, timezone
+import hashlib
+import json
+from pathlib import Path
+import re
+from check_health import fetch_json, validate_url
+from release_gate import assess
+
+
+def preflight(
+ *,
+ candidate,
+ current,
+ origin,
+ sql_file,
+ fetch=fetch_json,
+ admission=assess,
+ now=None
+):
+ if any(
+ not re.fullmatch(r"[0-9a-f]{40}", value or "") for value in (candidate, current)
+ ):
+ raise ValueError("Full current and candidate commits are required")
+ if candidate == current:
+ raise ValueError("Candidate must differ from current deployed API")
+ proof = admission(candidate) # Exact main-push workflow + all required jobs.
+ if proof.get("commit") != candidate:
+ raise ValueError("Candidate admission mismatch")
+ live_code, live = fetch(origin + "/health/live", 15)
+ ready_code, ready = fetch(origin + "/health/ready", 15)
+ if (
+ live_code != 200
+ or ready_code != 200
+ or live.get("status") != "alive"
+ or ready.get("status") != "ready"
+ or ready.get("database") != "connected"
+ or any(
+ body.get("release_commit") != current
+ or body.get("environment") != "production"
+ for body in (live, ready)
+ )
+ ):
+ raise ValueError(
+ "The current production API must already be awake, ready and on the expected commit"
+ )
+ source = Path(sql_file).read_bytes()
+ if not source:
+ raise ValueError("Reviewed SQL file is empty")
+ now = now or datetime.now(timezone.utc)
+ return dict(
+ approved_for_coordination=True,
+ not_authorization_to_apply=True,
+ candidate=candidate,
+ current=current,
+ sql_sha256=hashlib.sha256(source).hexdigest(),
+ checked_at=now.isoformat(),
+ expires_at=(now + timedelta(minutes=2)).isoformat(),
+ workflow_evidence=proof["workflows"],
+ next_step="Confirm replacement build has passed admission; apply rehearsed additive DDL immediately with rollout. Recheck if delayed. Never bypass schema admission or downgrade.",
+ )
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--candidate", required=True)
+ parser.add_argument("--current", required=True)
+ parser.add_argument("--url", required=True, type=validate_url)
+ parser.add_argument("--sql-file", required=True)
+ args = parser.parse_args()
+ try:
+ print(
+ json.dumps(
+ preflight(
+ candidate=args.candidate,
+ current=args.current,
+ origin=args.url,
+ sql_file=args.sql_file,
+ ),
+ sort_keys=True,
+ )
+ )
+ except Exception as error:
+ # Provider errors may contain URLs or credentials. Type only.
+ print(
+ json.dumps(
+ {"approved_for_coordination": False, "reason": type(error).__name__}
+ )
+ )
+ raise SystemExit(1)
diff --git a/backend/monitor_operations.py b/backend/monitor_operations.py
new file mode 100644
index 0000000..d523679
--- /dev/null
+++ b/backend/monitor_operations.py
@@ -0,0 +1,153 @@
+"""Read-only scheduled operations check. Native Actions failures provide alerts.
+
+No service restart, paid keep-alive, external recipients, business writes or secrets.
+"""
+
+import argparse
+import json
+import ssl
+import time
+from urllib.request import Request, urlopen
+from check_health import probe, validate_url, fetch_json
+
+ACTIONS = {
+ "readiness": "Inspect health/indexer reason and Render runtime logs; see OPERATIONS.md#incident-response.",
+ "api_errors": "Inspect structured request error_type/request_id and release; see OPERATIONS.md#incident-response.",
+ "event_stream": "Inspect event_stream_unavailable and database connectivity; see OPERATIONS.md#incident-response.",
+ "diagnostics": "Check deployment/schema and health/operations response; see OPERATIONS.md#incident-response.",
+}
+
+
+def stream_probe(origin, timeout=12):
+ start = time.monotonic()
+ with urlopen(
+ Request(origin + "/analytics/events", headers={"Accept": "text/event-stream"}),
+ timeout=timeout,
+ context=ssl.create_default_context(),
+ ) as response:
+ if response.status != 200 or not response.headers.get(
+ "Content-Type", ""
+ ).startswith("text/event-stream"):
+ return False
+ size = 0
+ while time.monotonic() - start < timeout:
+ line = response.readline(4097)
+ size += len(line)
+ if not line or len(line) > 4096 or size > 16384:
+ return False
+ if line.strip() in (b": heartbeat", b"event: heartbeat"):
+ return True
+ if line.strip() == b"event: unavailable":
+ return False
+ return False
+
+
+def assess_counters(body):
+ counters = body["counters"]
+ requests, errors = counters["api_requests"], counters["api_5xx"]
+ if (
+ any(type(v) is not int or v < 0 for v in (requests, errors))
+ or errors > requests
+ ):
+ return ["diagnostics"]
+ return ["api_errors"] if errors >= 3 else []
+
+
+def monitor(
+ origin,
+ *,
+ environment,
+ expect_indexer,
+ grace=240,
+ interval=15,
+ timeout=15,
+ readiness=probe,
+ fetch=fetch_json,
+ stream=stream_probe,
+ clock=time.monotonic,
+ sleep=time.sleep
+):
+ deadline = clock() + grace
+ consecutive = 0
+ observed = {"ready": False, "state": "unavailable"}
+ while clock() < deadline:
+ observed = readiness(
+ origin,
+ environment=environment,
+ expect_indexer=expect_indexer,
+ timeout=timeout,
+ )
+ consecutive = consecutive + 1 if observed["ready"] else 0
+ if consecutive >= 3:
+ break
+ sleep(min(interval, max(0, deadline - clock())))
+ failures = []
+ if consecutive < 3:
+ failures.append("readiness")
+ else:
+ try:
+ code, body = fetch(origin + "/health/operations", timeout)
+ if (
+ code != 200
+ or body["health"]["status"] != "ready"
+ or body["health"]["database"] != "connected"
+ or body["health"]["environment"] != environment
+ or body["health"]["indexer"]["enabled"] is not expect_indexer
+ ):
+ failures.append("diagnostics")
+ else:
+ failures.extend(assess_counters(body))
+ except Exception:
+ failures.append("diagnostics")
+ try:
+ if not stream(origin, timeout=12):
+ failures.append("event_stream")
+ except Exception:
+ failures.append("event_stream")
+ # Keep only allowlisted operational state; never echo arbitrary response text/URLs.
+ safe = {
+ key: observed[key]
+ for key in (
+ "ready",
+ "state",
+ "reason",
+ "lag_blocks",
+ "last_processed_block",
+ "target_head",
+ )
+ if key in observed
+ }
+ return dict(
+ healthy=not failures,
+ environment=environment,
+ expected_indexer=expect_indexer,
+ readiness=safe,
+ conditions=sorted(set(failures)),
+ actions=[ACTIONS[f] for f in sorted(set(failures))],
+ )
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--url", required=True, type=validate_url)
+ parser.add_argument(
+ "--environment", required=True, choices=["production", "testing"]
+ )
+ parser.add_argument(
+ "--expect-indexer", required=True, choices=["enabled", "disabled"]
+ )
+ parser.add_argument(
+ "--grace", type=int, default=240, choices=range(30, 301), metavar="30..300"
+ )
+ args = parser.parse_args()
+ result = monitor(
+ args.url,
+ environment=args.environment,
+ expect_indexer=args.expect_indexer == "enabled",
+ grace=args.grace,
+ )
+ print(json.dumps(result, sort_keys=True))
+ if not result["healthy"]:
+ for condition in result["conditions"]:
+ print("::error title=Veritras " + condition + "::" + ACTIONS[condition])
+ raise SystemExit(0 if result["healthy"] else 1)
diff --git a/backend/tests/test_operations.py b/backend/tests/test_operations.py
new file mode 100644
index 0000000..1aacf74
--- /dev/null
+++ b/backend/tests/test_operations.py
@@ -0,0 +1,339 @@
+import json
+import logging
+from datetime import datetime, timezone
+import pytest
+from app.services.operations import Operations
+from app.core.logging import JsonFormatter
+from monitor_operations import monitor, assess_counters
+from check_migration_window import preflight
+
+
+def test_counters_are_bounded_expire_and_reset():
+ now = [0.0]
+ metrics = Operations(lambda: now[0])
+ for i in range(30):
+ now[0] = i * 60
+ metrics.request(status=500, duration_ms=20)
+ state = metrics.snapshot()
+ assert len(metrics.requests) == 15 and state["api_5xx"] == 15
+ now[0] += 901
+ assert metrics.snapshot()["api_5xx"] == 0
+ assert state["storage"] == "process_memory" and state["reset_on_restart"]
+
+
+def test_health_requests_do_not_dilute_business_error_count():
+ metrics = Operations()
+ metrics.request(status=500, duration_ms=20)
+ metrics.request(status=503, duration_ms=10, business=False)
+ s = metrics.snapshot()
+ assert s["requests"] == 2 and s["api_5xx"] == 1 and s["api_requests"] == 1
+
+
+def test_telemetry_rejects_input_and_identity_fields(client_fixture):
+ valid = dict(
+ metric="LCP",
+ route="shipment_detail",
+ value=123,
+ environment="development",
+ release="a" * 40,
+ )
+ assert client_fixture.post("/telemetry/browser", json=valid).status_code == 204
+ for field in ("wallet", "token", "message", "url", "note", "signature", "user"):
+ assert (
+ client_fixture.post(
+ "/telemetry/browser", json=dict(valid, **{field: "secret"})
+ ).status_code
+ == 422
+ )
+ for field, value in [
+ ("environment", "production"),
+ ("route", "/shipments/SECRET"),
+ ("value", -1),
+ ("value", 600001),
+ ("release", "secret"),
+ ]:
+ assert (
+ client_fixture.post(
+ "/telemetry/browser", json=dict(valid, **{field: value})
+ ).status_code
+ == 422
+ )
+
+
+def test_public_operations_are_typed_aggregates(client_fixture):
+ response = client_fixture.get("/health/operations")
+ assert response.status_code == 200
+ body = response.json()
+ assert body["health"]["environment"] == "development"
+ assert body["counters"]["reset_on_restart"]
+ assert all(
+ "url" not in metric and "release" not in metric
+ for metric in body["counters"]["browser_metrics"]
+ )
+ assert response.headers["cache-control"] == "no-store"
+
+
+def test_request_logs_do_not_include_paths_input_ip_or_arbitrary_extras(
+ client_fixture, capfd, monkeypatch
+):
+ monkeypatch.setenv("RENDER_GIT_COMMIT", "a" * 40)
+ response = client_fixture.get("/products/SECRET-PRODUCT?token=SECRET-TOKEN")
+ records = [
+ json.loads(line)
+ for line in capfd.readouterr().out.splitlines()
+ if line.startswith("{")
+ ]
+ request = next(r for r in reversed(records) if r.get("message") == "request")
+ assert request["path"] == "/{product_id}"
+ assert request["release"] == "a" * 40 and "client_ip" not in request
+ assert request["request_id"] == response.headers["x-request-id"]
+ assert "SECRET" not in json.dumps(request)
+ record = logging.LogRecord(
+ "veritras.test", logging.ERROR, __file__, 1, "failure", (), None
+ )
+ record.token = "SECRET"
+ record.user = {"wallet": "SECRET"}
+ try:
+ raise ValueError("SECRET RPC https://credentials.invalid")
+ except ValueError:
+ import sys
+
+ record.exc_info = sys.exc_info()
+ output = JsonFormatter().format(record)
+ assert "SECRET" not in output and "credentials.invalid" not in output
+ assert "ValueError" in output
+
+
+def test_monitor_requires_sustained_readiness_and_checks_stream():
+ now = [0.0]
+ calls = []
+
+ def ready(*args, **kwargs):
+ calls.append(1)
+ return {
+ "ready": len(calls) > 1,
+ "state": "current" if len(calls) > 1 else "starting",
+ }
+
+ def sleep(seconds):
+ now[0] += seconds
+
+ result = monitor(
+ "https://example.invalid",
+ environment="testing",
+ expect_indexer=True,
+ grace=90,
+ readiness=ready,
+ fetch=lambda *a: (
+ 200,
+ {
+ "health": {
+ "status": "ready",
+ "database": "connected",
+ "environment": "testing",
+ "indexer": {"enabled": True},
+ },
+ "counters": {"api_requests": 10, "api_5xx": 0},
+ },
+ ),
+ stream=lambda *a, **k: True,
+ clock=lambda: now[0],
+ sleep=sleep,
+ )
+ assert result["healthy"] and len(calls) == 4
+
+
+def test_monitor_disabled_policy_is_expected_and_stream_failure_actionable():
+ now = [0.0]
+ result = monitor(
+ "https://example.invalid",
+ environment="production",
+ expect_indexer=False,
+ grace=90,
+ readiness=lambda *a, **k: {"ready": True, "state": "disabled"},
+ fetch=lambda *a: (
+ 200,
+ {
+ "health": {
+ "status": "ready",
+ "database": "connected",
+ "environment": "production",
+ "indexer": {"enabled": False},
+ },
+ "counters": {"api_requests": 3, "api_5xx": 3},
+ },
+ ),
+ stream=lambda *a, **k: False,
+ clock=lambda: now[0],
+ sleep=lambda t: now.__setitem__(0, now[0] + t),
+ )
+ assert result["conditions"] == ["api_errors", "event_stream"]
+ assert all("OPERATIONS.md" in action for action in result["actions"])
+
+
+def test_monitor_stale_indexer_fails_without_business_or_stream_calls():
+ now = [0.0]
+
+ def fail(*args, **kwargs):
+ raise AssertionError("No downstream probes while unavailable")
+
+ result = monitor(
+ "https://example.invalid",
+ environment="testing",
+ expect_indexer=True,
+ grace=30,
+ readiness=lambda *a, **k: {
+ "ready": False,
+ "state": "degraded",
+ "reason": "poll_stale",
+ },
+ fetch=fail,
+ stream=fail,
+ clock=lambda: now[0],
+ sleep=lambda t: now.__setitem__(0, now[0] + t),
+ )
+ assert (
+ result["conditions"] == ["readiness"]
+ and result["readiness"]["reason"] == "poll_stale"
+ )
+
+
+def test_invalid_counters_fail_closed():
+ assert assess_counters({"counters": {"api_requests": 1, "api_5xx": 2}}) == [
+ "diagnostics"
+ ]
+
+
+def test_migration_preflight_rejects_pending_ci_before_health(tmp_path):
+ sql = tmp_path / "migration.sql"
+ sql.write_text("CREATE TABLE example(id integer);")
+
+ def pending(commit):
+ raise RuntimeError("CI pending")
+
+ def no_health(*a):
+ raise AssertionError("Admission precedes warmup check")
+
+ with pytest.raises(RuntimeError):
+ preflight(
+ candidate="a" * 40,
+ current="b" * 40,
+ origin="https://example.invalid",
+ sql_file=sql,
+ admission=pending,
+ fetch=no_health,
+ )
+
+
+def test_migration_preflight_requires_awake_exact_prior_release(tmp_path):
+ sql = tmp_path / "migration.sql"
+ sql.write_text("CREATE TABLE example(id integer);")
+ proof = lambda commit: {"commit": commit, "workflows": []}
+ for code, commit in [(503, "b" * 40), (200, "c" * 40)]:
+ with pytest.raises(ValueError):
+ preflight(
+ candidate="a" * 40,
+ current="b" * 40,
+ origin="https://example.invalid",
+ sql_file=sql,
+ admission=proof,
+ fetch=lambda *a: (
+ code,
+ {
+ "status": "ready",
+ "release_commit": commit,
+ "database": "connected",
+ "environment": "production",
+ },
+ ),
+ )
+
+ def fetch(url, timeout):
+ return 200, dict(
+ status="alive" if url.endswith("live") else "ready",
+ database="connected",
+ release_commit="b" * 40,
+ environment="production",
+ )
+
+ result = preflight(
+ candidate="a" * 40,
+ current="b" * 40,
+ origin="https://example.invalid",
+ sql_file=sql,
+ admission=proof,
+ fetch=fetch,
+ now=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ )
+ assert result["approved_for_coordination"] and result["not_authorization_to_apply"]
+ assert result["expires_at"] == "2026-01-01T00:02:00+00:00"
+
+
+@pytest.mark.asyncio
+async def test_stream_failure_is_private_observable_and_releases_subscription(
+ monkeypatch,
+):
+ from app.api.routes import analytics
+ from app.services.operations import operations
+
+ baseline = operations.snapshot()["streams"]
+
+ def broken():
+ raise RuntimeError("SECRET connection string")
+
+ monkeypatch.setattr(analytics, "chain_revision", broken)
+ response = await analytics.analytics_events()
+ chunks = [chunk async for chunk in response.body_iterator]
+ assert chunks == ["event: unavailable\ndata: {}\n\n"]
+ result = operations.snapshot()["streams"]
+ assert result["failures"] == baseline["failures"] + 1
+ assert result["active"] == baseline["active"]
+
+
+@pytest.mark.asyncio
+async def test_stream_heartbeat_is_named_and_cancel_releases_subscription(monkeypatch):
+ import asyncio
+ from app.api.routes import analytics
+ from app.services.operations import operations
+
+ monkeypatch.setattr(analytics, "chain_revision", lambda: 0)
+
+ async def timeout(awaitable, **kwargs):
+ awaitable.close()
+ raise asyncio.TimeoutError
+
+ monkeypatch.setattr(analytics.asyncio, "wait_for", timeout)
+ baseline = operations.snapshot()["streams"]["active"]
+ response = await analytics.analytics_events()
+ iterator = response.body_iterator
+ assert await anext(iterator) == "event: resync\ndata: {}\n\n"
+ assert "event: heartbeat" in await anext(iterator)
+ await iterator.aclose()
+ assert operations.snapshot()["streams"]["active"] == baseline
+
+
+def test_monitor_rechecks_readiness_in_the_operations_snapshot():
+ now = [0.0]
+ result = monitor(
+ "https://example.invalid",
+ environment="testing",
+ expect_indexer=True,
+ grace=60,
+ readiness=lambda *a, **k: {"ready": True, "state": "current"},
+ fetch=lambda *a: (
+ 200,
+ {
+ "health": {
+ "status": "not_ready",
+ "database": "connected",
+ "environment": "testing",
+ "indexer": {"enabled": True},
+ },
+ "counters": {"api_requests": 1, "api_5xx": 0},
+ },
+ ),
+ stream=lambda *a, **k: True,
+ clock=lambda: now[0],
+ sleep=lambda t: now.__setitem__(0, now[0] + t),
+ )
+ assert result["healthy"] is False and result["conditions"] == ["diagnostics"]
diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts
index bc747e3..982178a 100644
--- a/frontend/e2e/dashboard.spec.ts
+++ b/frontend/e2e/dashboard.spec.ts
@@ -22,7 +22,7 @@ test('KPI cards render with mocked values', async ({ page }) => {
test('Shipment Volume chart section renders', async ({ page }) => {
await page.goto('/')
- await expect(page.getByText('Shipment Volume')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Shipment Volume', exact: true })).toBeVisible()
})
test('Recent Products section renders', async ({ page }) => {
diff --git a/frontend/e2e/helpers/routes.ts b/frontend/e2e/helpers/routes.ts
index 05afa22..4882ad6 100644
--- a/frontend/e2e/helpers/routes.ts
+++ b/frontend/e2e/helpers/routes.ts
@@ -47,6 +47,8 @@ function apiPath(url: URL, pathname: string): boolean {
}
export async function setupRoutes(page: Page): Promise
- How Veritras handles your data on the blockchain supply-chain platform.
+ How Veritras handles your data on the blockchain supply-chain
+ platform.
Last Updated: July 31, 2026 Last Updated: October 20, 2026
- Veritras is a blockchain-based supply chain transparency platform. This
- policy explains what data we collect, how we use it, and your rights.
+ Veritras is a blockchain-based supply chain transparency platform.
+ This policy explains what data we collect, how we use it, and your
+ rights.
Transactions on Ethereum / Sepolia are publicly visible on the
- blockchain by nature. Wallet addresses and transaction data are part of
- the public ledger and cannot be deleted.
+ blockchain by nature. Wallet addresses and transaction data are part
+ of the public ledger and cannot be deleted.
We do not sell your data. Data is shared only with hosting providers
- (Vercel, Render) under their respective privacy policies, and the
- blockchain network (public by design).
+ (Vercel, Render), an optional configured error-reporting provider,
+ under their respective privacy policies, and the blockchain network
+ (public by design).
Off-chain data is retained as long as your account is active. On-chain
data is permanent by design. You may request deletion of off-chain
- records at any time.
+ records at any time. Operational browser aggregates are held only in
+ API process memory and reset when that process restarts.
@@ -80,8 +86,8 @@ export default function Privacy() {