diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 58e1124..3632ed4 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -18,8 +18,13 @@ Ready. (cd monorepo && pnpm turbo run check-types lint) python3 scripts/check_prose.py - cp .env.example .env then set POSTGRES_PASSWORD, - TWIN_DB_PASSWORD and JWT_SECRET + cp .env.example .env for a new local environment + set separate POSTGRES_PASSWORD, + TWIN_DB_PASSWORD, API_DB_PASSWORD, + JWT_SECRET and TWIN_INTERNAL_TOKEN docker compose up --build dashboard on port 8090 + README.md existing-database setup and demo login + monorepo/README.md host development and full web checks + EOF diff --git a/.env.example b/.env.example index 1a34c28..d87c50a 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,18 @@ POSTGRES_PASSWORD=change-me POSTGRES_DB=wellhead_data POSTGRES_HOST=db POSTGRES_PORT=5432 +# Leave empty for a new stack's normal project volume. Set only after a verified +# engine upgrade to select the converted volume and retain the original. +TIMESCALE_DATA_VOLUME= -# Read-only database login for the internal twin-core service. +# Scoped twin login: metadata, snapshots, projections, and control application. TWIN_DB_USER=twin_reader TWIN_DB_PASSWORD=change-me-twin-readonly +# Separate API login, provisioned by the migrator. Use a URL-safe password. +API_DB_USER=well_api +API_DB_PASSWORD=change-me-api-application +# Shared secret for internal projections, control hints, and parameter validation. +TWIN_INTERNAL_TOKEN=change-me-to-a-separate-random-32-character-secret # Optional JSON object of validated model parameter overrides. TWIN_MODEL_OVERRIDES_JSON= @@ -16,7 +24,7 @@ MODBUS_HOST=modbus MODBUS_BIND_HOST=0.0.0.0 MODBUS_PORT=5020 -# How often the simulator emits a telemetry batch and how often ingestion +# How often the gateway publishes a model batch and how often ingestion # polls for one. These are deliberately the same value: when ingestion polled # faster than the simulator emitted, every simulated value was recorded to the # historian multiple times. @@ -30,7 +38,7 @@ PGADMIN_DEFAULT_PASSWORD=change-me JWT_SECRET=change-me-to-a-long-random-local-secret # Optional local API development value outside Docker Compose. -DATABASE_URL=postgres://admin:change-me@localhost:5434/wellhead_data +DATABASE_URL=postgres://well_api:change-me-api-application@localhost:5434/wellhead_data # Session cookie Secure flag. Keep false for the plain-HTTP local stack; set # true wherever TLS terminates. Previously inferred from NODE_ENV, which meant @@ -45,3 +53,7 @@ TRUSTED_PROXY_IPS= # Extra origins permitted to make state-changing requests, comma separated. # Same-origin is always allowed; the dashboard proxies /api so this stays empty. ALLOWED_ORIGINS= + +# Optional observability profile, separate Grafana administrator (32+ characters). +# Leave empty when using only the normal well stack. See observability/README.md. +GRAFANA_ADMIN_PASSWORD= diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 00a7087..2228495 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -37,7 +37,7 @@ and send it again rather than waiting. **In scope:** the API and its authentication and access control, the dashboard, the twin-core service, the Modbus gateway, the ingestion service, -the SQL migrations in `data/sql/migrations`, the Docker images and +the canonical SQL in `data/sql/schema.sql`, the Docker images and `docker-compose.yml`, and the CI workflow in `.github/workflows/`. **Out of scope**, because these are known and documented: @@ -54,9 +54,9 @@ the SQL migrations in `data/sql/migrations`, the Docker images and makes it worse. - Findings that need an already-compromised host. -The full list of known gaps is in -[docs/architecture/production-readiness.md](../docs/architecture/production-readiness.md). -Anything listed there as missing is a known gap, not a new finding. +The readiness record separates [open work and deployment limits](../docs/architecture/production-readiness.md#open-work-and-deployment-limits) +from verified local behavior. A documented limitation is known, but a report that +shows a failure within an implemented boundary is still useful. ## Disclosure diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb5568e..48cd99e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - name: Unit tests run: pnpm test:dto + - name: Website unit tests + run: pnpm test:website + python: name: Python (lint, types, tests) runs-on: ubuntu-latest @@ -93,7 +96,7 @@ jobs: # would verify almost nothing that matters. services: db: - image: timescale/timescaledb:latest-pg14 + image: timescale/timescaledb@sha256:6f139d56042989bd35f50ba5986e492cd32e35cc6778c50be2659add298dc09f env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -111,6 +114,9 @@ jobs: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres TEST_JWT_SECRET: ci-secret-of-at-least-thirty-two-characters + # Only the disposable CI database uses this public test credential. + API_DB_USER: well_api_test + API_DB_PASSWORD: ci-only-api-database-password steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -142,6 +148,14 @@ jobs: run: pnpm install --frozen-lockfile working-directory: monorepo + - name: Verify schema lifecycles and scheduled forecast transactions + run: >- + python -m pytest data/tests/test_schema_database.py + data/tests/test_schema_extension_database.py data/tests/test_forecast_database.py + data/tests/test_schema_normalize_database.py + env: + SCHEMA_DATABASE_TEST: '1' + - name: Run API integration tests run: pnpm --filter @well-simulation/api test working-directory: monorepo @@ -149,6 +163,9 @@ jobs: images: name: Container images runs-on: ubuntu-latest + env: + # Keep the built image names equal to the regression script defaults. + COMPOSE_PROJECT_NAME: well-simulation steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -162,3 +179,38 @@ jobs: - name: Build all service images run: docker compose build + + - name: Validate optional metrics configuration and rules + run: | + docker compose --profile observability run --rm --no-deps --entrypoint /bin/promtool prometheus check config /etc/prometheus/prometheus.yml + docker compose --profile observability run --rm --no-deps --workdir /etc/prometheus --entrypoint /bin/promtool prometheus test rules rules.test.yml + + - name: Verify dashboard proxy recovery + run: python3 scripts/verify_proxy_recovery.py + + - name: Install pnpm for browser verification + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.11.1 + + - name: Install Node for browser verification + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: monorepo/.nvmrc + + - name: Install browser test dependencies + working-directory: monorepo + run: | + pnpm install --frozen-lockfile + pnpm --filter @well-simulation/website exec playwright install --with-deps chromium + + - name: Start the demo stack + run: docker compose up -d --wait --wait-timeout 180 + + - name: Verify browser workflows + working-directory: monorepo + run: pnpm --filter @well-simulation/website test:browser + + - name: Stop the demo stack + if: always() + run: docker compose down --remove-orphans diff --git a/README.md b/README.md index a35732c..158f2c0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ TimescaleDB with the model tick time and source label. Industrial digital twin systems need more than dashboards. They need a reliable telemetry pipeline, asset metadata, historical storage, alarm logic, operational interfaces, and a process model that can explain and predict behavior. -This repository currently implements the telemetry and monitoring foundation. The documentation in `docs/` records the decisions required to evolve it into a production-grade lightweight digital twin. +This repository implements monitoring, a synthetic process model, restart recovery, +and saved forecasts and scenarios. The documentation in `docs/` records the +implemented decisions and the work still needed for a field-calibrated twin. ## Current Capabilities @@ -23,72 +25,125 @@ This repository currently implements the telemetry and monitoring foundation. Th - TimescaleDB/PostgreSQL historian for time-series readings. - PostgreSQL alarm rules and alarm event generation. - TypeScript API with OpenAPI documentation. -- React dashboard with overview, analytics, per-wellhead detail views, and an - Alarm Center. +- React dashboard with a fleet overview, analytics, an Alarm Center, and per-well + monitoring, forecasts, scenario comparisons, and saved-run history. - Health, readiness, and metrics endpoints for the API, telemetry services, - and the new twin-core service boundary. + and twin-core. - A twin-core service with one-second process steps, deterministic per-well variation, bounded model state, and source-labelled historian reads. +- Durable model snapshots, parameter overrides, and recorded restart recovery. +- Saved on-demand and scheduled forecasts, plus isolated what-if scenarios + from current state or an exact compatible historical snapshot. +- Draft, activate, cancel, and status APIs for live synthetic controls, with + durable revisions and a separate administrator parameter publication path. +- Reviewed control and parameter screens, and audited alarm acknowledgement + and timed shelving for OPERATIONS users. +- An optional Prometheus and Grafana profile for service metrics. - Docker-based local orchestration. ## Current Dashboard Scope -The dashboard currently focuses on credible monitoring rather than pretending to be a finished digital twin: +The dashboard connects monitoring with the existing model analysis: - Fleet overview for all simulated wellheads. - Current readings grouped by wellhead and parameter. - Pressure, temperature, flow, water cut, and GOR trend views. - Per-wellhead detail pages with active alarms and parameter history. -- An Alarm Center with severity, threshold, age, and links to wellheads. - -The Alarm Center has no acknowledge or shelve buttons because the backend cannot -store those states yet. - -## Target Direction - -The target system is a lightweight wellhead digital twin with: - -- Stateful process simulation per wellhead. -- Control inputs such as choke position, valve state, and pump state. -- Prediction and what-if simulation APIs. -- Historical comparison between observed and predicted behavior. -- Production-grade deployment, observability, security, and documentation. +- An Alarm Center with condition, acknowledgement, timed shelves and action history. +- Model status, successful process time, recovery details, and installed controls. +- Forecast and scenario forms with shared input validation. +- Saved comparison charts, original inputs, confidence reasons, and value tables. +- Per-well history with direct links that reopen results without running the model. +- Historical scenarios from exact compatible model snapshots, with saved origin metadata. +- Administrator-owned forecast schedules, with the latest attempt and latest + completed result shown separately. + +OPERATIONS can acknowledge an exact event, shelve a well’s rule for 5–480 minutes, +or end a shelf early. Each action records a reason and an audited receipt. Active +counts still include acknowledged and shelved conditions. See the +[alarm workflow](docs/design/alarm-lifecycle.md). + +## Model boundary and further work + +The synthetic model already supports state, control inputs and isolated +predictions. Field use would require measured well data, calibration and +independent validation. Measured-versus-model comparison, divergence detection +and a physical control connection remain outside the implemented scope. + +The [readiness record](docs/architecture/production-readiness.md) separates local +verification from open delivery items and requirements for another deployment. ## What happens next An audit of this project found problems in the pipeline, the schema, access -control, and the documentation. Repair phases 0 to 8 are complete. Phase 9 -reconciles the remaining documentation and checks startup from a clean copy. +control, and the documentation. Repair phases 0 to 9 are complete as of +2026-10-13. Phase 9 reconciled the documentation and verified a fresh installation. The local `docs/roadmap.md` records feature work, and the local `docs/remediation-roadmap.md` records the repair. Both are ignored by Git. The committed design documents and ADRs record the implemented decisions. - -The model now feeds the Modbus gateway and historian. Durable twin state -snapshots, forecasts, and what-if APIs follow in later milestones. +The approved usability, operating-screen, historical-scenario, scheduled-forecast, +metrics and alarm work is committed through `82c9baf` and verified locally. + +Milestones 4 through 8 are implemented: the model feeds Modbus and the historian, +recovers saved state, and runs forecasts and private scenarios. The +[8B design](docs/design/twin-core-milestone-8b.md) covers the implemented live +synthetic control lifecycle and its verification. +The [operating workspace design](docs/design/operating-workspace.md) covers its +control and parameter screens, including reviewed proposals and outcome recovery. +The [Milestone 9 design](docs/design/twin-core-milestone-9.md) covers the implemented +wellhead workspace and its verification. Milestone 10's local delivery checks +passed with an isolated clean candidate export. Remote CI, publication and +licence contributor consent remain open. +The login page now uses an original text layout without the old photograph. The owner has deferred the physical host sleep/wake test. +Milestone 9 now includes the complete [mockup gallery](docs/design/milestone-9/mockups/index.html), +[shared visual specification](docs/design/milestone-9/visual-specification.md), +and implemented accessibility improvements. The +[verification record](docs/design/milestone-9/accessibility-verification.md) +describes the Chromium and Orca checks and the combinations not tested. +The [usability design](docs/design/dashboard-usability.md) records the revised +page structure, optional Guide, two design checks and the next delivery sequence. +The published [ADR index](docs/adr/README.md) records decision status; local +roadmaps are working notes, not dependencies for understanding the project. ## Quick Start +New installations use PostgreSQL 16.15 and TimescaleDB 2.30.2. For an existing +database, first select its path in the +[existing-database update guide](docs/operations/database-updates.md). +PostgreSQL 14 volumes require the matching legacy baseline and verified engine +conversion before the current database image can use them. + ```bash -cp .env.example .env # then set POSTGRES_PASSWORD, TWIN_DB_PASSWORD, and JWT_SECRET +cp .env.example .env +# Set POSTGRES_PASSWORD, TWIN_DB_PASSWORD, API_DB_PASSWORD, JWT_SECRET, +# and TWIN_INTERNAL_TOKEN to separate random values before starting. docker compose up --build ``` -The `migrator` service applies the ordered SQL migrations in `data/sql/migrations` -before the API or the telemetry services start. Schema changes do **not** require -recreating the volume. +The `migrator` service installs `data/sql/schema.sql` on a new database and +verifies its recorded baseline on later starts. A database from the numbered +runner needs adoption with its matching historical installer and contract. +Today's `adopt` does not add missing schedule or alarm tables. Follow the +[update sequence](docs/operations/database-updates.md) for adoption, engine +acceptance and table extension. Changes to existing definitions need a reviewed +conversion. These steps preserve the existing data. +The engine conversion preserves the original volume and selects the verified +new volume through TIMESCALE_DATA_VOLUME. + +For an existing `.env`, add `API_DB_USER=well_api` and a separate URL-safe +`API_DB_PASSWORD` before upgrading. The migrator creates this restricted +application account. The API no longer uses the database administrator login. -The dashboard is served at . The current production -image returns HTTP 200 but renders a blank page in a real browser because its -Content Security Policy blocks React Router's inline startup scripts. Sign-in -will work after that deployment issue is fixed: +The dashboard is served at . Sign in with the demo +account: | Account | Role | Password | | --- | --- | --- | | `demo@example.com` | `USER` | `demo-operator-2026` | -This account can view the dashboard. What-if scenarios are planned, not built. -The password is in the repository on purpose. It only gives read access to -made-up telemetry on a local stack. +This account can view the dashboard and save forecasts and what-if scenarios +through its forms or the API. It cannot change the running well's controls. +The password is in the repository on purpose for the local synthetic demo. You cannot sign yourself up. Only an `ADMIN` can create a user, as set out in [ADR 0033](docs/adr/0033-role-model-and-access-control.md). The migrator creates @@ -117,22 +172,36 @@ users. | Acknowledge or shelve an alarm | no | yes | no | | Issue a live control command | no | yes | no | | Manage users | no | no | yes | +| Publish model parameters or edit forecast schedules | no | no | yes | | Edit alarm rules and asset metadata | no | no | yes | -Only dashboard access and administrator user creation are implemented in this -table. `OPERATIONS` guards nothing yet. Scenarios, alarm acknowledgement, the -control API, and metadata editing are not built. +Dashboard access, forecasts, scenarios, and administrator user creation are +implemented. OPERATIONS users can issue live synthetic controls through the +draft and activation API. ADMIN users can publish model parameter overrides +that require a twin-core restart. OPERATIONS can acknowledge events and manage +timed shelves in the Alarm Center. General alarm-rule and asset editing remain +outside the implemented administration screens. Logout revokes all existing sessions and prevents +commands queued by those sessions from applying. ## Documentation - `docs/README.md`: index of everything in `docs/` -- `docs/architecture/overview.md`: the current architecture and the target one -- `docs/architecture/production-readiness.md`: what production-grade means here +- `docs/architecture/overview.md`: current services, data paths and model boundaries +- `docs/architecture/production-readiness.md`: local evidence, open work and deployment limits - `docs/adr/README.md`: the architecture decision records - `docs/openapi/README.md`: the API contracts - `docs/design/twin-core-milestone-4.md`: the twin-core skeleton design - `docs/design/twin-core-milestone-5.md`: the process model equations and limits - `docs/design/twin-core-milestone-6.md`: the model-to-Modbus data path +- `docs/design/twin-core-milestone-7.md`: twin persistence design and verification +- `docs/design/twin-core-milestone-8.md`: forecasts, scenarios, and saved results +- `docs/design/twin-core-milestone-8b.md`: live control lifecycle and operating steps +- `docs/design/twin-core-milestone-9.md`: dashboard layout, interactions, and verification +- `docs/design/historical-scenarios.md`: exact saved-state branches +- `docs/design/scheduled-forecasts.md`: owned schedules and durable jobs +- `docs/design/alarm-lifecycle.md`: acknowledgement, shelving and receipts +- `docs/operations/database-updates.md`: the correct path for each database baseline +- `observability/README.md`: optional metrics startup and troubleshooting - `docs/design/reference-well-physics.md`: the phase and diagnostic equations - `docs/roadmap.md`: the local feature plan (ignored by Git) - `docs/remediation-roadmap.md`: the local repair record (ignored by Git) @@ -143,8 +212,9 @@ Every reading is invented. New values are calculated by the reduced-order model and passed through Modbus. Older random readings remain labelled in the historian until retention removes them. The model links flow, pressure, temperature, water cut, gas, sand, corrosion, annulus pressure, and vibration, but it has not been calibrated against a real well. -No new readings are produced for annulus pressure, gas-oil ratio, sand, corrosion -rate, H2S, CO2, or vibration because the model has no equations for them. +Seven of these (annulus pressure, gas-oil ratio, sand, corrosion rate, H2S, CO2, +and vibration) come from simpler diagnostic equations added in model version +0.3.0. They are less grounded than the pressure and flow equations. Two more things are compressed for a demo rather than set the way a plant would set them: @@ -156,19 +226,94 @@ set them: Raw readings are kept for 7 days and alarm events for 90. +## Screenshots + +Captured on 2026-10-17 from the running Milestone 9 dashboard. Forecast and +scenario images show actual saved model results. The synthetic operating point +shown in the monitoring views has no active alarms. + +- [Fleet overview](docs/screenshots/overview.png) +- [Analytics with continuous trends](docs/screenshots/analytics.png) +- [Wellhead detail](docs/screenshots/wellhead-detail.png) +- [Alarm Center](docs/screenshots/alarm-center.png) +- [Saved forecast](docs/screenshots/forecast.png) +- [Scenario comparison](docs/screenshots/scenario.png) +- [Saved-run history](docs/screenshots/saved-runs.png) +- [Historical scenario](docs/screenshots/historical-scenario.png) +- [Historical scenario on a phone](docs/screenshots/historical-scenario-mobile.png) +- [Scenario on a phone-sized viewport](docs/screenshots/scenario-mobile.png) + +## Browser verification + +Start a healthy local stack, then run from the monorepo: + +```bash +cd monorepo +pnpm install --frozen-lockfile +pnpm --filter @well-simulation/website exec playwright install chromium +pnpm --filter @well-simulation/website test:browser +``` + +The test signs in with the demo account and saves isolated model runs. It checks +that the installed controls and revision do not change. It also checks mobile +layouts, keyboard focus, saved links, stale data, and failed requests. Set +UPDATE_SCREENSHOTS=1 when intentionally refreshing the committed screenshots; +ordinary test runs write their images under the ignored test-results directory. +CI installs Chromium and its system dependencies before running this workflow. + ## Where this project actually stands -The services run against the existing database volume. A separate clean-start -check also passed with a newly created volume: the migrator exited successfully, -all services became healthy, and the API and twin-core reported ready. Each -image was built separately after a combined Docker Buildx run stopped the -daemon. The Compose health check requires TCP database readiness, which avoids -the startup race found in an earlier clean-start check. +The operating screens use the same reviewed-command API as Milestone 8B. +OPERATIONS users see Controls; ADMIN users see Parameters. An administrator +creates operating accounts through the existing account API. The seeded demo +reader keeps its current permissions. See the [operating verification](docs/design/operating-workspace.md#verification-on-2026-10-17) +for the real command, historian and parameter-restart checks. + +| Controls | Parameters | +| --- | --- | +| ![Reviewed running control command](docs/screenshots/controls.png) | ![Model parameter editor with canonical units and bounds](docs/screenshots/parameters.png) | + +[Control proposal on a phone](docs/screenshots/controls-mobile.png) + +On 2026-10-13, a clean export of commit `e3d44c7` passed +`docker compose up -d --build` with a fresh volume. All six images built, +the migrator exited with code 0, and all six long-running services became +healthy. The API and twin-core returned ready, with all 12 model wells running. +The verification used separate container names, loopback ports, network, and +volume to preserve the existing stack. The test export contained committed +application files; only those isolation settings and generated test secrets +differed. + +The same isolated stack then passed an upgrade to the working Milestone 8B code. +A live synthetic command flowed through the model, Modbus, and historian; +restart preserved its controls. Parameter publication adopted a new parameter +set after restart. The four existing dashboard pages passed browser checks. +See the [8B verification record](docs/design/twin-core-milestone-8b.md#verification-on-2026-10-13). The API contract is written down under `docs/openapi`. +On 2026-10-16, the Milestone 9 workspace passed its local API, HTTP, unit, and +browser checks. Real forecasts and scenarios were saved and reopened, while +installed controls and revision stayed unchanged. Desktop and mobile layouts, +keyboard focus, stale data, and failure recovery passed. All six services +reported healthy. See the [Milestone 9 verification record](docs/design/twin-core-milestone-9.md#verification-on-2026-10-16). +The completed design pass also passed 37 shared-contract/frontend tests, all +three browser workflows, automated accessibility scans, and an Orca speech-output +check. The mockups passed 96 screen/state/viewport combinations. See the +[accessibility verification record](docs/design/milestone-9/accessibility-verification.md) +for the exact scope and limitations. + +On 2026-10-17, the usability update passed five browser workflows, 37 shared +contract/frontend tests, 95 API integration tests and 14 model HTTP tests. +An isolated export of the complete candidate built all six service images and +started with fresh storage using the example configuration. Schema setup exited +0 and all six running services became healthy. A browser read all 12 wells, +saved and reopened a scenario, and confirmed unchanged installed controls. +The [usability delivery record](docs/design/dashboard-usability.md#delivery-evidence-2026-10-17) +records the design checks and remaining external delivery items. + It is not production ready. The twin-core model uses synthetic, uncalibrated -parameters and has no durable snapshots or live device control. The API, gateway, -ingestion, and twin-core services expose metrics, but no metrics collection stack -is deployed. -Several accepted ADRs are still unimplemented. The -[ADR index](docs/adr/README.md) tracks the gap between decisions and code. +parameters and has no field calibration or physical device control. The optional [service metrics profile](observability/README.md) collects API, +gateway, ingestion and twin-core metrics in Prometheus and provides a Grafana +Service health dashboard. +The [ADR index](docs/adr/README.md) tracks completed local decisions and the +remaining external portfolio delivery gates. diff --git a/data/README.md b/data/README.md index 4339362..64df6e0 100644 --- a/data/README.md +++ b/data/README.md @@ -10,12 +10,13 @@ The telemetry pipeline and the database schema. The dashboard and API live in | `src/wellhead_simulator.py` | Legacy random simulator, no longer started by Compose | | `src/modbus_gateway.py` | Fetches model snapshots and serves supported values as holding registers | | `src/database_ingestion.py` | Polls Modbus and writes model readings to the historian | -| `src/schema.py` | Applies the ordered SQL migrations | +| `src/schema.py` | Installs, checks, adopts or extends a schema, and accepts a verified engine change | | `src/model_source.py` | Validates model snapshots and maps all 18 seeded signals | | `src/telemetry_common.py` | Config, logging, database access, and the health endpoints | | `src/telemetry_metrics.py` | Prometheus metric definitions | -| `sql/migrations/` | The schema. Canonical, per ADR 0006 | -| `sql/seeds/` | Demo fleet and accounts, applied after the migrations | +| `sql/schema.sql` | The complete current schema, per ADR 0006 | +| `sql/schema-contract.json` | Generated comparison record and legacy checksums | +| `sql/seeds/` | Demo fleet and accounts, applied after schema verification | ## How the data flows @@ -25,6 +26,14 @@ complete fleet snapshot every telemetry interval, validates it, and writes the and stores the model tick time and source label in TimescaleDB. The former random simulator script remains as historical code but is not started by the gateway. +Twin-core saves its process state about every 30 model seconds in +`twin_state_snapshot`. A restart checks the saved model version and parameter +fingerprint, then restores each compatible well. It records the decision in +`twin_recovery_event`. Gaps longer than five seconds are counted, and the +model does not invent readings for missing time. The +[Milestone 7 design](../docs/design/twin-core-milestone-7.md) gives the +recovery equations and failure rules. + The full mapping and failure rules are in the [Milestone 6 design](../docs/design/twin-core-milestone-6.md). The [reference-well design](../docs/design/reference-well-physics.md) defines the synthetic equations and their limits. ## Metadata drives everything @@ -32,10 +41,13 @@ The full mapping and failure rules are in the [Milestone 6 design](../docs/desig The services do not hardcode which wellheads or parameters exist. Each queries the database on startup and configures itself from what it finds. -Each also re-reads that metadata every telemetry interval, so adding a wellhead, -moving a Modbus address, or changing an alarm threshold takes effect within a -few seconds without a restart. ADR 0035 records why a timer was chosen over a -notification or a reload endpoint. +The gateway and ingestion re-read mappings every telemetry interval (five +seconds by default). Twin-core refreshes its active fleet and parameter +overrides every 30 seconds by default. A changed parameter marks an existing +model `restart_required`; refresh does not apply it during a tick. Alarm +evaluation reads current rules when readings arrive. The +[ADR 0035 amendment](../docs/adr/0035-metadata-reload.md#amendment-2026-10-17-separate-metadata-refresh-from-operating-changes) +distinguishes timed refresh, parameter adoption and durable control commands. When a register mapping moves, the gateway clears its whole register image. A value left at an address nothing maps any more would be a stale reading on a @@ -43,21 +55,28 @@ live interface. ## Telling stale data from fresh -The gateway writes zero to heartbeat register 1900 before changing values, then -writes the model tick's UTC epoch second after the complete register update. -Ingestion reads it before and after a poll. A missing, unchanged, too-old, or -changed-mid-poll heartbeat prevents an insert. `timestamp_utc` is the model -tick, while `inserted_at` is the database arrival time. Both are UTC. +The gateway writes zero to the status registers 1900 to 1904 before changing +values. After the complete register update it writes the model tick's UTC epoch +second (1900 and 1901) and the model version (1902 to 1904, major, minor, and +patch) in one write. Ingestion reads all five before and after a poll. A +missing, unchanged, too-old, or changed-mid-poll heartbeat or version prevents +an insert. When an update lands mid-poll, ingestion polls the new tick at once +instead of waiting a whole interval. The complete read must finish before +half the polling interval has elapsed, even if its start and end status match. +A late batch is discarded and the next scheduled poll proceeds normally. +`timestamp_utc` is the model tick, while `inserted_at` is the database arrival +time. Both are UTC. `model_version` records which model calculated the reading. ## Alarms Alarm rules live in `alarmRule`. A statement-level trigger on `parameterReading` evaluates each batch. -Alarms have hysteresis: three consecutive breaching readings to open one, three -consecutive normal ones to clear it. Without that, randomised telemetry made -alarms flap continuously and `alarmEvent` filled with events that lasted a -single sample. +Each rule has `trigger_after` and `clear_after` counts. The seeded defaults +require three consecutive breaching readings to open an event and three normal +readings to clear it. These counts prevent a single fluctuating sample from +opening or clearing a condition. Acknowledgement and shelving live in separate +tables and never clear the condition. See [alarm lifecycle records](#alarm-lifecycle-records). ## Operational endpoints @@ -70,14 +89,85 @@ ready once it has written readings, and not ready while it is reconnecting. ## Running it -The root `README.md` has the quick start. The migrator applies -`sql/migrations/` on every `up`, so a schema change does not need the volume -recreated. +The root `README.md` has the quick start. The service still named `migrator` +runs the installer. It installs `sql/schema.sql` on an empty database or checks +an already adopted database. It refuses an unexpected or incomplete schema. +Compose and CI pin PostgreSQL 16.15 with TimescaleDB 2.30.2. An existing +PostgreSQL 14 volume needs the [engine upgrade procedure](../docs/design/database-engine-upgrade.md) +before it can run with this image. A new empty installation uses the current +image directly. + +### Existing database adoption + +Follow the [existing-database update guide](../docs/operations/database-updates.md) +before building the current installer for an older installation. It separates +legacy adoption, engine acceptance and additions to the current schema. + +For the original PostgreSQL 14 legacy database, use the matching `cd09691` +installer, SQL and contract to adopt its complete 13-file history. Verify +physical recovery, convert a copy, then use `e150992` for engine acceptance with +the same schema source. Finally, use the current `extend` command to add the +schedule and alarm tables. A PostgreSQL 16 installation already at a known +baseline can start at the extension step. + +Today's `adopt` cannot add missing tables to a legacy database. It requires +the actual structure to match its supplied contract. The guide provides commands +that keep each installer with its matching source and preserve the existing data. + +`check` reads identity, history, and structure without applying SQL or grants. +Use `--without-seeds` with `init` to omit demo records and the bootstrap account. +That flag does not remove existing records. + +### Changing the schema + +Edit `sql/schema.sql`. Generate `sql/schema-contract.json` from a fresh disposable +database using `python data/src/schema.py build-reference --contract-output PATH`, +with `POSTGRES_*` pointing to that disposable database. The command refuses a +populated application schema. Review the generated differences and run +the schema, extension and comparison database checks. + +Run these commands from the repository root with the Python dependencies +installed and database settings exported. Python does not load `.env` itself; +the [update guide](../docs/operations/database-updates.md#command-environment) +shows an explicit loader. Run the checks with: + +```sh +SCHEMA_DATABASE_TEST=1 python -m pytest data/tests/test_schema_database.py data/tests/test_schema_extension_database.py data/tests/test_schema_normalize_database.py +``` + +The tests use `TEST_POSTGRES_HOST`, `TEST_POSTGRES_PORT`, `POSTGRES_USER`, and +`POSTGRES_PASSWORD`, creating and deleting only their own random database names. + +Do not edit the generated record by hand. It records structure and the source +checksum, not a second schema to maintain. Keep the Drizzle mirror consistent. +An existing database whose baseline differs needs a separately reviewed +conversion plan. Editing the file does not automatically upgrade stored data. + +An interrupted installation stays marked incomplete. Automatic retries do not +resume statements or delete objects. Inspect the saved progress and recover +from a verified backup, or explicitly rebuild an expendable test database. + +### Changing the database engine + +Follow the [engine upgrade design](../docs/design/database-engine-upgrade.md). +It separates a PostgreSQL major-version upgrade from the TimescaleDB extension +update and verifies both on a copy before changing the running stack. +Save the previous generated contract before building the new one. +After the upgrade, use the matching engine-only installer with +`accept-engine --previous-contract PATH` to record the changed engine identity. +Its schema source must match the previous contract. Add later tables separately, +following the [update sequence](../docs/operations/database-updates.md). +Normal startup never accepts an engine change implicitly. + +TIMESCALE_DATA_VOLUME selects the verified converted Docker volume. Leave it +empty for a new stack to use its normal project-specific volume. Keeping the +old default name makes an unconverted PostgreSQL 14 volume fail to start under +PostgreSQL 16, rather than silently starting an empty historian. To inspect the data: ```sh -docker exec -it wellhead_db psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" +docker compose exec db sh -c 'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' ``` ```sql @@ -86,6 +176,25 @@ WHERE wellhead_id = 1 AND parameter_type_id = 1 ORDER BY timestamp_utc DESC LIMIT 10; ``` +## Changing one model parameter + +The model defaults and valid units are in +`twin_core/model_defaults.json`. An ADMIN can now create a parameter proposal through +POST /api/twin/parameter-changes and explicitly publish it. The API validates +the proposed value and unit through twin-core and records the authenticated +actor. Publication inserts an append-only wellhead_model_parameter row and +its audit event in one transaction. See the +[8B operating steps](../docs/design/twin-core-milestone-8b.md#operating-the-api). + +A published parameter does not change a running model during a tick. Fleet +refresh marks a changed model restart_required. Restart twin-core after +reviewing the publication. To remove an override, publish null for both value +and unit. The lower-precedence environment or default value then applies. + +Manual SQL remains possible for the database administrator, but bypasses the +application's verified identity and review steps. Normal operation uses the +proposal API. Historical manual records keep their original actor text. + ## What the numbers are The new readings come from a reduced-order synthetic model, not a field device. @@ -93,3 +202,24 @@ Pressure, flow, temperature, water cut, valve state, and pump state now share one model tick. They are uncalibrated and unsuitable for plant decisions. Rows written before this change retain `synthetic_random_simulator` in `source_kind`; new rows carry `synthetic_reduced_order_model`. + +### Add ordinary tables to an existing installation + +The `extend` command checks the installed schema against its previous committed +SQL and contract, then applies only new ordinary tables and their indexes from +the current canonical SQL. It verifies and audits the new identity in the same +transaction. Keep temporary copies of the installed SQL and contract, take a +private backup, and verify a restored copy first. See the [extension workflow](../docs/design/schema-extension.md) and [command example](../docs/design/consolidated-schema.md#adding-new-ordinary-tables). + +### Alarm lifecycle records + +`alarm_action` stores immutable reviewed input and its result. +`alarm_acknowledgement` identifies one event by its bigint ID and exact trigger +time. `alarm_shelf` stores the current well/rule revision and deadline. All three +come from the single canonical SQL file. The API can insert receipts and +acknowledgements but cannot change or delete existing ones. Its shelf UPDATE +grant names only the revision, deadline, updated time and receipt columns. + +Operator actions never update `alarmevent`. Its 90-day retention remains in +place, while action receipts and acknowledgement metadata remain durable. See +the [alarm design](../docs/design/alarm-lifecycle.md) for expiry and audit behavior. diff --git a/data/sql/migrations/0001_core_schema.sql b/data/sql/migrations/0001_core_schema.sql deleted file mode 100644 index 3fac14d..0000000 --- a/data/sql/migrations/0001_core_schema.sql +++ /dev/null @@ -1,147 +0,0 @@ --- Core relational schema, hypertables, and indexes. --- --- Idempotent: safe to apply to a database originally created by the previous --- docker-entrypoint-initdb.d bootstrap, and to a fresh one. - -CREATE EXTENSION IF NOT EXISTS timescaledb; - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'status') THEN - CREATE TYPE "status" AS ENUM ('ACTIVE', 'DISABLED'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role') THEN - CREATE TYPE "role" AS ENUM ('USER', 'ADMIN', 'OPERATIONS'); - END IF; -END $$; - -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - email VARCHAR(256) NOT NULL UNIQUE, - first_name VARCHAR(256), - last_name VARCHAR(256), - user_name VARCHAR(256) NOT NULL UNIQUE, - phone_number VARCHAR(256) UNIQUE, - bio TEXT, - image VARCHAR(256), - role "role" DEFAULT 'USER', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - status "status" DEFAULT 'ACTIVE', - deleted BOOLEAN DEFAULT FALSE, - deleted_at TIMESTAMPTZ, - encrypted_password VARCHAR(256) NOT NULL, - last_signed_in TIMESTAMPTZ, - updated_at TIMESTAMPTZ DEFAULT NOW() -); - --- Asset hierarchy ----------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS field ( - field_id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE, - description TEXT -); - -CREATE TABLE IF NOT EXISTS location ( - location_id SERIAL PRIMARY KEY, - field_id INT NOT NULL REFERENCES field(field_id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - address TEXT, - latitude FLOAT, - longitude FLOAT -); - -CREATE TABLE IF NOT EXISTS device ( - device_id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE, - modbus_unit_id INT NOT NULL, - status VARCHAR(50) DEFAULT 'active' -); - -CREATE TABLE IF NOT EXISTS wellHead ( - wellhead_id SERIAL PRIMARY KEY, - location_id INT NOT NULL REFERENCES location(location_id) ON DELETE CASCADE, - device_id INT UNIQUE NOT NULL REFERENCES device(device_id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL UNIQUE, - type VARCHAR(100), - status VARCHAR(50) DEFAULT 'active' -); - --- Parameter metadata -------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS parameterType ( - parameter_type_id SERIAL PRIMARY KEY, - code VARCHAR(100) NOT NULL UNIQUE, - display_name VARCHAR(255) NOT NULL, - canonical_unit VARCHAR(50), - data_type VARCHAR(50) NOT NULL, - normal_min FLOAT, - normal_max FLOAT -); - -CREATE TABLE IF NOT EXISTS deviceParameterMapping ( - mapping_id SERIAL PRIMARY KEY, - device_id INT NOT NULL REFERENCES device(device_id) ON DELETE CASCADE, - parameter_type_id INT NOT NULL REFERENCES parameterType(parameter_type_id) ON DELETE CASCADE, - modbus_register INT NOT NULL, - function_code SMALLINT NOT NULL, - register_type VARCHAR(50) NOT NULL, - active BOOLEAN DEFAULT TRUE, - UNIQUE(device_id, modbus_register) -); - --- Time-series --------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS parameterReading ( - parameter_reading_id BIGSERIAL, - timestamp_utc TIMESTAMPTZ NOT NULL, - wellhead_id INT NOT NULL REFERENCES wellHead(wellhead_id) ON DELETE CASCADE, - parameter_type_id INT NOT NULL REFERENCES parameterType(parameter_type_id) ON DELETE CASCADE, - mapping_id INT NOT NULL REFERENCES deviceParameterMapping(mapping_id) ON DELETE CASCADE, - raw_value FLOAT NOT NULL, - inserted_at TIMESTAMPTZ DEFAULT NOW(), - PRIMARY KEY (parameter_reading_id, timestamp_utc) -); - -SELECT create_hypertable('parameterReading', 'timestamp_utc', if_not_exists => TRUE); - --- Every per-wellhead query filters on exactly this pair; no index covered it. -CREATE INDEX IF NOT EXISTS ix_parameter_reading_wellhead_time - ON parameterReading (wellhead_id, timestamp_utc DESC); - -CREATE INDEX IF NOT EXISTS ix_parameter_reading_type_time - ON parameterReading (parameter_type_id, timestamp_utc DESC); - --- Alarms -------------------------------------------------------------------- - -CREATE TABLE IF NOT EXISTS alarmRule ( - alarm_rule_id SERIAL PRIMARY KEY, - parameter_type_id INT NOT NULL REFERENCES parameterType(parameter_type_id) ON DELETE CASCADE, - severity_level VARCHAR(50) NOT NULL, - operator VARCHAR(10) NOT NULL, - threshold_value FLOAT NOT NULL, - active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE UNIQUE INDEX IF NOT EXISTS uq_alarm_rule_definition - ON alarmRule (parameter_type_id, severity_level, operator, threshold_value); - -CREATE TABLE IF NOT EXISTS alarmEvent ( - event_id BIGSERIAL NOT NULL, - alarm_rule_id INT NOT NULL REFERENCES alarmRule(alarm_rule_id) ON DELETE CASCADE, - parameter_reading_id BIGINT NOT NULL, - timestamp_utc TIMESTAMPTZ NOT NULL, - wellhead_id INT NOT NULL REFERENCES wellHead(wellhead_id) ON DELETE CASCADE, - triggered_at TIMESTAMPTZ NOT NULL, - cleared_at TIMESTAMPTZ, - severity_level VARCHAR(50) NOT NULL, - triggered_value FLOAT NOT NULL, - PRIMARY KEY (event_id, triggered_at) -); - -SELECT create_hypertable('alarmEvent', 'triggered_at', if_not_exists => TRUE); - --- The active-alarms view filters on exactly this predicate. -CREATE INDEX IF NOT EXISTS ix_alarm_event_open - ON alarmEvent (wellhead_id, alarm_rule_id) WHERE cleared_at IS NULL; diff --git a/data/sql/migrations/0002_alarm_evaluation.sql b/data/sql/migrations/0002_alarm_evaluation.sql deleted file mode 100644 index ed37855..0000000 --- a/data/sql/migrations/0002_alarm_evaluation.sql +++ /dev/null @@ -1,141 +0,0 @@ --- Alarm evaluation with hysteresis, evaluated once per insert statement. --- --- Replaces a per-row AFTER INSERT trigger that looped over rules and ran an --- EXISTS query for each one: roughly 216 rule evaluations per ingestion batch, --- inside the insert transaction. It also cleared an alarm on the first in-range --- reading, so against randomised telemetry alarms flapped continuously and --- alarmEvent grew without bound from events that lasted a single sample. - --- How many consecutive evaluations must agree before an alarm changes state. -ALTER TABLE alarmRule - ADD COLUMN IF NOT EXISTS trigger_after INT NOT NULL DEFAULT 3; -ALTER TABLE alarmRule - ADD COLUMN IF NOT EXISTS clear_after INT NOT NULL DEFAULT 3; - -COMMENT ON COLUMN alarmRule.trigger_after IS - 'Consecutive breaching evaluations required before the alarm opens.'; -COMMENT ON COLUMN alarmRule.clear_after IS - 'Consecutive non-breaching evaluations required before the alarm clears.'; - --- Per wellhead and rule, how many consecutive evaluations have agreed. Keeping --- the counters here makes evaluation O(1) per rule instead of a scan back --- through reading history. -CREATE TABLE IF NOT EXISTS alarmRuleState ( - wellhead_id INT NOT NULL REFERENCES wellHead(wellhead_id) ON DELETE CASCADE, - alarm_rule_id INT NOT NULL REFERENCES alarmRule(alarm_rule_id) ON DELETE CASCADE, - consecutive_breaches INT NOT NULL DEFAULT 0, - consecutive_clears INT NOT NULL DEFAULT 0, - last_evaluated_at TIMESTAMPTZ, - PRIMARY KEY (wellhead_id, alarm_rule_id) -); - -CREATE OR REPLACE FUNCTION evaluate_wellhead_alarms() -RETURNS TRIGGER AS $$ -BEGIN - -- One evaluation per (wellhead, rule) per statement. The ingestion service - -- writes one reading per wellhead and parameter per batch, so the latest - -- row in the batch is that evaluation. A batch carrying several samples for - -- the same parameter counts as one evaluation, which delays a state change - -- rather than causing a wrong one. - WITH latest AS ( - SELECT DISTINCT ON (i.wellhead_id, i.parameter_type_id) - i.wellhead_id, - i.parameter_type_id, - i.parameter_reading_id, - i.timestamp_utc, - i.raw_value - FROM inserted i - ORDER BY i.wellhead_id, i.parameter_type_id, i.timestamp_utc DESC - ), - evaluated AS ( - SELECT l.wellhead_id, - r.alarm_rule_id, - r.severity_level, - r.trigger_after, - r.clear_after, - l.parameter_reading_id, - l.timestamp_utc, - l.raw_value, - CASE r.operator - WHEN '>' THEN l.raw_value > r.threshold_value - WHEN '>=' THEN l.raw_value >= r.threshold_value - WHEN '<' THEN l.raw_value < r.threshold_value - WHEN '<=' THEN l.raw_value <= r.threshold_value - WHEN '==' THEN l.raw_value = r.threshold_value - ELSE FALSE - END AS breaching - FROM latest l - JOIN alarmRule r - ON r.parameter_type_id = l.parameter_type_id - AND r.active = TRUE - ), - counted AS ( - INSERT INTO alarmRuleState AS s ( - wellhead_id, alarm_rule_id, - consecutive_breaches, consecutive_clears, last_evaluated_at - ) - SELECT e.wellhead_id, - e.alarm_rule_id, - CASE WHEN e.breaching THEN 1 ELSE 0 END, - CASE WHEN e.breaching THEN 0 ELSE 1 END, - e.timestamp_utc - FROM evaluated e - ON CONFLICT (wellhead_id, alarm_rule_id) DO UPDATE - SET consecutive_breaches = CASE - WHEN EXCLUDED.consecutive_breaches > 0 - THEN s.consecutive_breaches + 1 ELSE 0 END, - consecutive_clears = CASE - WHEN EXCLUDED.consecutive_clears > 0 - THEN s.consecutive_clears + 1 ELSE 0 END, - last_evaluated_at = EXCLUDED.last_evaluated_at - RETURNING s.wellhead_id, s.alarm_rule_id, - s.consecutive_breaches, s.consecutive_clears - ), - decided AS ( - SELECT e.*, c.consecutive_breaches, c.consecutive_clears - FROM evaluated e - JOIN counted c - ON c.wellhead_id = e.wellhead_id - AND c.alarm_rule_id = e.alarm_rule_id - ), - opened AS ( - INSERT INTO alarmEvent ( - alarm_rule_id, parameter_reading_id, wellhead_id, timestamp_utc, - triggered_at, severity_level, triggered_value - ) - SELECT d.alarm_rule_id, d.parameter_reading_id, d.wellhead_id, - d.timestamp_utc, d.timestamp_utc, d.severity_level, d.raw_value - FROM decided d - WHERE d.breaching - AND d.consecutive_breaches >= d.trigger_after - AND NOT EXISTS ( - SELECT 1 FROM alarmEvent a - WHERE a.wellhead_id = d.wellhead_id - AND a.alarm_rule_id = d.alarm_rule_id - AND a.cleared_at IS NULL - ) - RETURNING 1 - ) - UPDATE alarmEvent a - SET cleared_at = d.timestamp_utc - FROM decided d - WHERE a.wellhead_id = d.wellhead_id - AND a.alarm_rule_id = d.alarm_rule_id - AND a.cleared_at IS NULL - AND NOT d.breaching - AND d.consecutive_clears >= d.clear_after; - - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS wellhead_readings_alarm_trigger ON parameterReading; -DROP TRIGGER IF EXISTS evaluate_wellhead_alarms_trigger ON parameterReading; - -CREATE TRIGGER evaluate_wellhead_alarms_trigger -AFTER INSERT ON parameterReading -REFERENCING NEW TABLE AS inserted -FOR EACH STATEMENT -EXECUTE FUNCTION evaluate_wellhead_alarms(); - -DROP FUNCTION IF EXISTS check_wellhead_alarms(); diff --git a/data/sql/migrations/0003_views.sql b/data/sql/migrations/0003_views.sql deleted file mode 100644 index e53ed25..0000000 --- a/data/sql/migrations/0003_views.sql +++ /dev/null @@ -1,58 +0,0 @@ --- Denormalised read views for the dashboard API. --- --- Dropped and recreated rather than CREATE OR REPLACE: replacing a view can --- only append columns, and v_active_alarms gains parameter_code and --- canonical_unit in the middle of its column list so the Alarm Center can show --- the parameter and unit alongside the threshold. - -DROP VIEW IF EXISTS v_wellhead_parameter_readings; -DROP VIEW IF EXISTS v_active_alarms; - -CREATE VIEW v_wellhead_parameter_readings AS -SELECT - pr.timestamp_utc, - pr.raw_value, - wh.wellhead_id, - wh.name AS wellhead_name, - wh.type AS wellhead_type, - loc.location_id, - loc.name AS location_name, - f.field_id, - f.name AS field_name, - pt.parameter_type_id, - pt.code AS parameter_code, - pt.display_name AS parameter_display_name, - pt.canonical_unit, - pt.data_type, - pt.normal_min, - pt.normal_max -FROM parameterReading pr -JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id -JOIN location loc ON wh.location_id = loc.location_id -JOIN field f ON loc.field_id = f.field_id -JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id; - --- Open alarms with the threshold context needed to explain why they fired. -CREATE VIEW v_active_alarms AS -SELECT - ae.event_id, - ae.triggered_at, - ae.severity_level, - ae.triggered_value, - wh.wellhead_id, - wh.name AS wellhead_name, - loc.name AS location_name, - f.name AS field_name, - pt.code AS parameter_code, - pt.display_name AS parameter_display_name, - pt.canonical_unit, - ar.operator, - ar.threshold_value, - ar.alarm_rule_id -FROM alarmEvent ae -JOIN wellHead wh ON ae.wellhead_id = wh.wellhead_id -JOIN location loc ON wh.location_id = loc.location_id -JOIN field f ON loc.field_id = f.field_id -JOIN alarmRule ar ON ae.alarm_rule_id = ar.alarm_rule_id -JOIN parameterType pt ON ar.parameter_type_id = pt.parameter_type_id -WHERE ae.cleared_at IS NULL; diff --git a/data/sql/migrations/0004_continuous_aggregates.sql b/data/sql/migrations/0004_continuous_aggregates.sql deleted file mode 100644 index a0204dc..0000000 --- a/data/sql/migrations/0004_continuous_aggregates.sql +++ /dev/null @@ -1,133 +0,0 @@ --- migrate:no-transaction --- --- Continuous aggregates. TimescaleDB refuses to create these inside a --- transaction block, so this file is applied with autocommit. --- --- The previous names described behaviour these views never had: the "hourly" --- aggregates bucket at 2 minutes and the "daily" ones at 5 minutes, with --- columns called bucket_day and daily_avg_value. The buckets are deliberately --- compressed so a demo shows a trend within minutes rather than days; the --- names now say what they actually do. - -DROP MATERIALIZED VIEW IF EXISTS mv_hourly_pressure_trends CASCADE; -DROP MATERIALIZED VIEW IF EXISTS mv_hourly_temp_flow_trends CASCADE; -DROP MATERIALIZED VIEW IF EXISTS mv_hourly_water_cut_gor_trends CASCADE; -DROP MATERIALIZED VIEW IF EXISTS mv_daily_alarm_counts CASCADE; - --- Created, refreshed every five minutes, and read by nothing. -DROP MATERIALIZED VIEW IF EXISTS mv_daily_all_parameter_summary CASCADE; - -DROP MATERIALIZED VIEW IF EXISTS mv_2min_pressure_trends CASCADE; -DROP MATERIALIZED VIEW IF EXISTS mv_2min_temp_flow_trends CASCADE; -DROP MATERIALIZED VIEW IF EXISTS mv_2min_water_cut_gor_trends CASCADE; -DROP MATERIALIZED VIEW IF EXISTS mv_5min_alarm_counts CASCADE; - -CREATE MATERIALIZED VIEW mv_2min_pressure_trends -WITH (timescaledb.continuous) AS -SELECT - time_bucket('2 minutes', pr.timestamp_utc) AS bucket_time, - pr.wellhead_id, - wh.name AS wellhead_name, - loc.name AS location_name, - f.name AS field_name, - pt.code AS parameter_code, - pt.display_name AS parameter_display_name, - pt.canonical_unit, - AVG(pr.raw_value) AS avg_value, - MIN(pr.raw_value) AS min_value, - MAX(pr.raw_value) AS max_value, - COUNT(pr.raw_value) AS reading_count -FROM parameterReading pr -JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id -JOIN location loc ON wh.location_id = loc.location_id -JOIN field f ON loc.field_id = f.field_id -JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id -WHERE pt.code IN ('tubing_pressure', 'casing_pressure', 'annulus_pressure') -GROUP BY 1, pr.wellhead_id, wh.name, loc.name, f.name, - pt.code, pt.display_name, pt.canonical_unit; - -SELECT add_continuous_aggregate_policy('mv_2min_pressure_trends', - start_offset => INTERVAL '1 day', - end_offset => INTERVAL '2 minutes', - schedule_interval => INTERVAL '2 minutes'); - -CREATE MATERIALIZED VIEW mv_2min_temp_flow_trends -WITH (timescaledb.continuous) AS -SELECT - time_bucket('2 minutes', pr.timestamp_utc) AS bucket_time, - pr.wellhead_id, - wh.name AS wellhead_name, - loc.name AS location_name, - f.name AS field_name, - pt.code AS parameter_code, - pt.display_name AS parameter_display_name, - pt.canonical_unit, - AVG(pr.raw_value) AS avg_value, - MIN(pr.raw_value) AS min_value, - MAX(pr.raw_value) AS max_value, - COUNT(pr.raw_value) AS reading_count -FROM parameterReading pr -JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id -JOIN location loc ON wh.location_id = loc.location_id -JOIN field f ON loc.field_id = f.field_id -JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id -WHERE pt.code IN ('wellhead_temperature', 'flow_rate') -GROUP BY 1, pr.wellhead_id, wh.name, loc.name, f.name, - pt.code, pt.display_name, pt.canonical_unit; - -SELECT add_continuous_aggregate_policy('mv_2min_temp_flow_trends', - start_offset => INTERVAL '1 day', - end_offset => INTERVAL '2 minutes', - schedule_interval => INTERVAL '2 minutes'); - -CREATE MATERIALIZED VIEW mv_2min_water_cut_gor_trends -WITH (timescaledb.continuous) AS -SELECT - time_bucket('2 minutes', pr.timestamp_utc) AS bucket_time, - pr.wellhead_id, - wh.name AS wellhead_name, - loc.name AS location_name, - f.name AS field_name, - pt.code AS parameter_code, - pt.display_name AS parameter_display_name, - pt.canonical_unit, - AVG(pr.raw_value) AS avg_value, - MIN(pr.raw_value) AS min_value, - MAX(pr.raw_value) AS max_value, - COUNT(pr.raw_value) AS reading_count -FROM parameterReading pr -JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id -JOIN location loc ON wh.location_id = loc.location_id -JOIN field f ON loc.field_id = f.field_id -JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id -WHERE pt.code IN ('water_cut', 'gas_oil_ratio') -GROUP BY 1, pr.wellhead_id, wh.name, loc.name, f.name, - pt.code, pt.display_name, pt.canonical_unit; - -SELECT add_continuous_aggregate_policy('mv_2min_water_cut_gor_trends', - start_offset => INTERVAL '1 day', - end_offset => INTERVAL '2 minutes', - schedule_interval => INTERVAL '2 minutes'); - -CREATE MATERIALIZED VIEW mv_5min_alarm_counts -WITH (timescaledb.continuous) AS -SELECT - time_bucket('5 minutes', ae.triggered_at) AS bucket_time, - ae.wellhead_id, - wh.name AS wellhead_name, - loc.name AS location_name, - pt.display_name AS parameter_display_name, - ae.severity_level, - COUNT(ae.event_id) AS alarms_triggered -FROM alarmEvent ae -JOIN wellHead wh ON ae.wellhead_id = wh.wellhead_id -JOIN location loc ON wh.location_id = loc.location_id -JOIN alarmRule ar ON ae.alarm_rule_id = ar.alarm_rule_id -JOIN parameterType pt ON ar.parameter_type_id = pt.parameter_type_id -GROUP BY 1, ae.wellhead_id, wh.name, loc.name, - pt.display_name, ae.severity_level; - -SELECT add_continuous_aggregate_policy('mv_5min_alarm_counts', - start_offset => INTERVAL '30 days', - end_offset => INTERVAL '5 minutes', - schedule_interval => INTERVAL '5 minutes'); diff --git a/data/sql/migrations/0005_retention_and_compression.sql b/data/sql/migrations/0005_retention_and_compression.sql deleted file mode 100644 index 435cf79..0000000 --- a/data/sql/migrations/0005_retention_and_compression.sql +++ /dev/null @@ -1,33 +0,0 @@ --- migrate:no-transaction --- --- Tiered retention and compression, implementing ADR 0026. --- --- Intervals are sized for a demonstrable portfolio system rather than a --- production historian: at a five-second interval, 12 wellheads and 18 --- parameters produce roughly 3.7 million rows per day, so raw readings are --- compressed quickly and dropped after a week. The continuous aggregates keep --- the longer-range trend. - -ALTER TABLE parameterReading SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'wellhead_id, parameter_type_id', - timescaledb.compress_orderby = 'timestamp_utc DESC' -); - -ALTER TABLE alarmEvent SET ( - timescaledb.compress, - timescaledb.compress_segmentby = 'wellhead_id, alarm_rule_id', - timescaledb.compress_orderby = 'triggered_at DESC' -); - --- Compress raw readings once they are older than the trend windows that read --- them uncompressed. -SELECT add_compression_policy('parameterReading', INTERVAL '1 day', - if_not_exists => TRUE); -SELECT add_compression_policy('alarmEvent', INTERVAL '7 days', - if_not_exists => TRUE); - -SELECT add_retention_policy('parameterReading', INTERVAL '7 days', - if_not_exists => TRUE); -SELECT add_retention_policy('alarmEvent', INTERVAL '90 days', - if_not_exists => TRUE); diff --git a/data/sql/migrations/0006_audit_event.sql b/data/sql/migrations/0006_audit_event.sql deleted file mode 100644 index 6bc6e05..0000000 --- a/data/sql/migrations/0006_audit_event.sql +++ /dev/null @@ -1,55 +0,0 @@ --- migrate:no-transaction --- --- The audit trail, implementing ADR 0034. --- --- Nothing in this system recorded who did anything. This table answers who did --- what, when, and to which record. It is built for the person operating the --- system rather than for a regulator, so there is no hash chain and no separate --- store. ADR 0034 records why. - -CREATE TABLE IF NOT EXISTS auditEvent ( - audit_event_id BIGSERIAL, - occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Null when no known user acted, which is every failed login against an - -- account that does not exist. - actor_user_id INT REFERENCES users(id) ON DELETE SET NULL, - - -- What the caller submitted. Recorded whether or not it matches an account, - -- so the presence of a row cannot be used to discover which accounts exist. - -- Never populated from the password field. - actor_identifier VARCHAR(256), - - action VARCHAR(64) NOT NULL, - subject_type VARCHAR(64), - subject_id VARCHAR(64), - outcome VARCHAR(16) NOT NULL, - - -- Ties the row to one request and to the log lines that request produced. - request_id VARCHAR(64), - - detail JSONB NOT NULL DEFAULT '{}'::jsonb, - - PRIMARY KEY (audit_event_id, occurred_at), - CONSTRAINT audit_event_outcome CHECK (outcome IN ('success', 'failure')) -); - -SELECT create_hypertable('auditEvent', 'occurred_at', if_not_exists => TRUE); - --- Reading the trail means asking what one actor did, or what happened to one --- subject, almost always most recent first. -CREATE INDEX IF NOT EXISTS ix_audit_event_actor - ON auditEvent (actor_user_id, occurred_at DESC); -CREATE INDEX IF NOT EXISTS ix_audit_event_action - ON auditEvent (action, occurred_at DESC); -CREATE INDEX IF NOT EXISTS ix_audit_event_subject - ON auditEvent (subject_type, subject_id, occurred_at DESC); - --- Failed logins arrive from unauthenticated traffic, so the identifier is the --- column an investigation starts from. -CREATE INDEX IF NOT EXISTS ix_audit_event_identifier - ON auditEvent (actor_identifier, occurred_at DESC) - WHERE actor_identifier IS NOT NULL; - -SELECT add_retention_policy('auditEvent', INTERVAL '90 days', - if_not_exists => TRUE); diff --git a/data/sql/migrations/0006_latest_mapping_read_index.sql b/data/sql/migrations/0006_latest_mapping_read_index.sql deleted file mode 100644 index df448b5..0000000 --- a/data/sql/migrations/0006_latest_mapping_read_index.sql +++ /dev/null @@ -1,4 +0,0 @@ --- One indexed lookup per active mapping keeps the twin-core's latest-reading --- endpoint bounded as historian data grows. -CREATE INDEX IF NOT EXISTS ix_parameter_reading_mapping_time - ON parameterReading (mapping_id, timestamp_utc DESC, parameter_reading_id DESC); diff --git a/data/sql/migrations/0007_model_reading_source.sql b/data/sql/migrations/0007_model_reading_source.sql deleted file mode 100644 index 1705932..0000000 --- a/data/sql/migrations/0007_model_reading_source.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Keep legacy random readings distinct from model-generated Modbus readings. -ALTER TABLE parameterReading - ADD COLUMN IF NOT EXISTS source_kind TEXT NOT NULL - DEFAULT 'synthetic_random_simulator'; - -ALTER TABLE parameterReading - ADD CONSTRAINT parameter_reading_source_kind_check - CHECK (source_kind IN ( - 'synthetic_random_simulator', 'synthetic_reduced_order_model' - )); diff --git a/data/sql/schema-contract.json b/data/sql/schema-contract.json new file mode 100644 index 0000000..e15185d --- /dev/null +++ b/data/sql/schema-contract.json @@ -0,0 +1,781 @@ +{ + "catalog": { + "aggregates": [ + "[\"mv_2min_pressure_trends\", \"public\", \"parameterreading\", true, false, \" SELECT time_bucket('00:02:00'::interval, pr.timestamp_utc) AS bucket_time,\\n pr.wellhead_id,\\n wh.name AS wellhead_name,\\n loc.name AS location_name,\\n f.name AS field_name,\\n pt.code AS parameter_code,\\n pt.display_name AS parameter_display_name,\\n pt.canonical_unit,\\n avg(pr.raw_value) AS avg_value,\\n min(pr.raw_value) AS min_value,\\n max(pr.raw_value) AS max_value,\\n count(pr.raw_value) AS reading_count\\n FROM ((((parameterreading pr\\n JOIN wellhead wh ON ((pr.wellhead_id = wh.wellhead_id)))\\n JOIN location loc ON ((wh.location_id = loc.location_id)))\\n JOIN field f ON ((loc.field_id = f.field_id)))\\n JOIN parametertype pt ON ((pr.parameter_type_id = pt.parameter_type_id)))\\n WHERE ((pt.code)::text = ANY ((ARRAY['tubing_pressure'::character varying, 'casing_pressure'::character varying, 'annulus_pressure'::character varying])::text[]))\\n GROUP BY (time_bucket('00:02:00'::interval, pr.timestamp_utc)), pr.wellhead_id, wh.name, loc.name, f.name, pt.code, pt.display_name, pt.canonical_unit;\", true]", + "[\"mv_2min_temp_flow_trends\", \"public\", \"parameterreading\", true, false, \" SELECT time_bucket('00:02:00'::interval, pr.timestamp_utc) AS bucket_time,\\n pr.wellhead_id,\\n wh.name AS wellhead_name,\\n loc.name AS location_name,\\n f.name AS field_name,\\n pt.code AS parameter_code,\\n pt.display_name AS parameter_display_name,\\n pt.canonical_unit,\\n avg(pr.raw_value) AS avg_value,\\n min(pr.raw_value) AS min_value,\\n max(pr.raw_value) AS max_value,\\n count(pr.raw_value) AS reading_count\\n FROM ((((parameterreading pr\\n JOIN wellhead wh ON ((pr.wellhead_id = wh.wellhead_id)))\\n JOIN location loc ON ((wh.location_id = loc.location_id)))\\n JOIN field f ON ((loc.field_id = f.field_id)))\\n JOIN parametertype pt ON ((pr.parameter_type_id = pt.parameter_type_id)))\\n WHERE ((pt.code)::text = ANY ((ARRAY['wellhead_temperature'::character varying, 'flow_rate'::character varying])::text[]))\\n GROUP BY (time_bucket('00:02:00'::interval, pr.timestamp_utc)), pr.wellhead_id, wh.name, loc.name, f.name, pt.code, pt.display_name, pt.canonical_unit;\", true]", + "[\"mv_2min_water_cut_gor_trends\", \"public\", \"parameterreading\", true, false, \" SELECT time_bucket('00:02:00'::interval, pr.timestamp_utc) AS bucket_time,\\n pr.wellhead_id,\\n wh.name AS wellhead_name,\\n loc.name AS location_name,\\n f.name AS field_name,\\n pt.code AS parameter_code,\\n pt.display_name AS parameter_display_name,\\n pt.canonical_unit,\\n avg(pr.raw_value) AS avg_value,\\n min(pr.raw_value) AS min_value,\\n max(pr.raw_value) AS max_value,\\n count(pr.raw_value) AS reading_count\\n FROM ((((parameterreading pr\\n JOIN wellhead wh ON ((pr.wellhead_id = wh.wellhead_id)))\\n JOIN location loc ON ((wh.location_id = loc.location_id)))\\n JOIN field f ON ((loc.field_id = f.field_id)))\\n JOIN parametertype pt ON ((pr.parameter_type_id = pt.parameter_type_id)))\\n WHERE ((pt.code)::text = ANY ((ARRAY['water_cut'::character varying, 'gas_oil_ratio'::character varying])::text[]))\\n GROUP BY (time_bucket('00:02:00'::interval, pr.timestamp_utc)), pr.wellhead_id, wh.name, loc.name, f.name, pt.code, pt.display_name, pt.canonical_unit;\", true]", + "[\"mv_5min_alarm_counts\", \"public\", \"alarmevent\", true, false, \" SELECT time_bucket('00:05:00'::interval, ae.triggered_at) AS bucket_time,\\n ae.wellhead_id,\\n wh.name AS wellhead_name,\\n loc.name AS location_name,\\n pt.display_name AS parameter_display_name,\\n ae.severity_level,\\n count(ae.event_id) AS alarms_triggered\\n FROM ((((alarmevent ae\\n JOIN wellhead wh ON ((ae.wellhead_id = wh.wellhead_id)))\\n JOIN location loc ON ((wh.location_id = loc.location_id)))\\n JOIN alarmrule ar ON ((ae.alarm_rule_id = ar.alarm_rule_id)))\\n JOIN parametertype pt ON ((ar.parameter_type_id = pt.parameter_type_id)))\\n GROUP BY (time_bucket('00:05:00'::interval, ae.triggered_at)), ae.wellhead_id, wh.name, loc.name, pt.display_name, ae.severity_level;\", true]" + ], + "columns": [ + "[\"alarm_acknowledgement\", \"acknowledged_at\", 3, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"alarm_acknowledgement\", \"action_id\", 4, \"uuid\", true, \"\", \"\", null, null]", + "[\"alarm_acknowledgement\", \"event_id\", 1, \"bigint\", true, \"\", \"\", null, null]", + "[\"alarm_acknowledgement\", \"triggered_at\", 2, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"action\", 7, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"alarm_action\", \"action_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"actor_name\", 3, \"character varying(256)\", true, \"\", \"\", null, \"default\"]", + "[\"alarm_action\", \"actor_role\", 4, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"alarm_action\", \"actor_user_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"alarm_rule_id\", 9, \"integer\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"input\", 11, \"jsonb\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"input_hash\", 6, \"character varying(64)\", true, \"\", \"\", null, \"default\"]", + "[\"alarm_action\", \"occurred_at\", 10, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"request_key\", 5, \"uuid\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"result\", 12, \"jsonb\", true, \"\", \"\", null, null]", + "[\"alarm_action\", \"wellhead_id\", 8, \"integer\", true, \"\", \"\", null, null]", + "[\"alarm_shelf\", \"action_id\", 6, \"uuid\", true, \"\", \"\", null, null]", + "[\"alarm_shelf\", \"alarm_rule_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"alarm_shelf\", \"revision\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"alarm_shelf\", \"shelved_until\", 4, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"alarm_shelf\", \"updated_at\", 5, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"alarm_shelf\", \"wellhead_id\", 1, \"integer\", true, \"\", \"\", null, null]", + "[\"alarmevent\", \"alarm_rule_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"alarmevent\", \"cleared_at\", 7, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"alarmevent\", \"event_id\", 1, \"bigint\", true, \"\", \"\", \"nextval('alarmevent_event_id_seq'::regclass)\", null]", + "[\"alarmevent\", \"parameter_reading_id\", 3, \"bigint\", true, \"\", \"\", null, null]", + "[\"alarmevent\", \"severity_level\", 8, \"character varying(50)\", true, \"\", \"\", null, \"default\"]", + "[\"alarmevent\", \"timestamp_utc\", 4, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"alarmevent\", \"triggered_at\", 6, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"alarmevent\", \"triggered_value\", 9, \"double precision\", true, \"\", \"\", null, null]", + "[\"alarmevent\", \"wellhead_id\", 5, \"integer\", true, \"\", \"\", null, null]", + "[\"alarmrule\", \"active\", 6, \"boolean\", false, \"\", \"\", \"true\", null]", + "[\"alarmrule\", \"alarm_rule_id\", 1, \"integer\", true, \"\", \"\", \"nextval('alarmrule_alarm_rule_id_seq'::regclass)\", null]", + "[\"alarmrule\", \"clear_after\", 9, \"integer\", true, \"\", \"\", \"3\", null]", + "[\"alarmrule\", \"created_at\", 7, \"timestamp with time zone\", false, \"\", \"\", \"now()\", null]", + "[\"alarmrule\", \"operator\", 4, \"character varying(10)\", true, \"\", \"\", null, \"default\"]", + "[\"alarmrule\", \"parameter_type_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"alarmrule\", \"severity_level\", 3, \"character varying(50)\", true, \"\", \"\", null, \"default\"]", + "[\"alarmrule\", \"threshold_value\", 5, \"double precision\", true, \"\", \"\", null, null]", + "[\"alarmrule\", \"trigger_after\", 8, \"integer\", true, \"\", \"\", \"3\", null]", + "[\"alarmrulestate\", \"alarm_rule_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"alarmrulestate\", \"consecutive_breaches\", 3, \"integer\", true, \"\", \"\", \"0\", null]", + "[\"alarmrulestate\", \"consecutive_clears\", 4, \"integer\", true, \"\", \"\", \"0\", null]", + "[\"alarmrulestate\", \"last_evaluated_at\", 5, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"alarmrulestate\", \"wellhead_id\", 1, \"integer\", true, \"\", \"\", null, null]", + "[\"auditevent\", \"action\", 5, \"character varying(64)\", true, \"\", \"\", null, \"default\"]", + "[\"auditevent\", \"actor_identifier\", 4, \"character varying(256)\", false, \"\", \"\", null, \"default\"]", + "[\"auditevent\", \"actor_user_id\", 3, \"integer\", false, \"\", \"\", null, null]", + "[\"auditevent\", \"audit_event_id\", 1, \"bigint\", true, \"\", \"\", \"nextval('auditevent_audit_event_id_seq'::regclass)\", null]", + "[\"auditevent\", \"detail\", 10, \"jsonb\", true, \"\", \"\", \"'{}'::jsonb\", null]", + "[\"auditevent\", \"occurred_at\", 2, \"timestamp with time zone\", true, \"\", \"\", \"now()\", null]", + "[\"auditevent\", \"outcome\", 8, \"character varying(16)\", true, \"\", \"\", null, \"default\"]", + "[\"auditevent\", \"request_id\", 9, \"character varying(64)\", false, \"\", \"\", null, \"default\"]", + "[\"auditevent\", \"subject_id\", 7, \"character varying(64)\", false, \"\", \"\", null, \"default\"]", + "[\"auditevent\", \"subject_type\", 6, \"character varying(64)\", false, \"\", \"\", null, \"default\"]", + "[\"control_command\", \"activated_by\", 18, \"integer\", false, \"\", \"\", null, null]", + "[\"control_command\", \"activated_role\", 19, \"text\", false, \"\", \"\", null, \"default\"]", + "[\"control_command\", \"activated_session_version\", 20, \"integer\", false, \"\", \"\", null, null]", + "[\"control_command\", \"choke_position_percent\", 9, \"double precision\", false, \"\", \"\", null, null]", + "[\"control_command\", \"command_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"control_command\", \"created_at\", 15, \"timestamp with time zone\", true, \"\", \"\", \"clock_timestamp()\", null]", + "[\"control_command\", \"created_by\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"control_command\", \"emergency_shutdown\", 13, \"boolean\", false, \"\", \"\", null, null]", + "[\"control_command\", \"expected_revision\", 6, \"integer\", true, \"\", \"\", null, null]", + "[\"control_command\", \"expires_at\", 16, \"timestamp with time zone\", true, \"\", \"\", \"(clock_timestamp() + '24:00:00'::interval)\", null]", + "[\"control_command\", \"idempotency_key\", 4, \"uuid\", true, \"\", \"\", null, null]", + "[\"control_command\", \"master_valve_open\", 10, \"boolean\", false, \"\", \"\", null, null]", + "[\"control_command\", \"model_version\", 7, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"control_command\", \"parameter_fingerprint\", 8, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"control_command\", \"pump_on\", 12, \"boolean\", false, \"\", \"\", null, null]", + "[\"control_command\", \"queued_at\", 17, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"control_command\", \"reason\", 5, \"character varying(500)\", true, \"\", \"\", null, \"default\"]", + "[\"control_command\", \"request_id\", 24, \"character varying(64)\", false, \"\", \"\", null, \"default\"]", + "[\"control_command\", \"result_at\", 21, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"control_command\", \"result_reason\", 23, \"character varying(64)\", false, \"\", \"\", null, \"default\"]", + "[\"control_command\", \"result_revision\", 22, \"integer\", false, \"\", \"\", null, null]", + "[\"control_command\", \"status\", 14, \"text\", true, \"\", \"\", \"'draft'::text\", \"default\"]", + "[\"control_command\", \"wellhead_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"control_command\", \"wing_valve_open\", 11, \"boolean\", false, \"\", \"\", null, null]", + "[\"control_command_event\", \"actor_user_id\", 5, \"integer\", false, \"\", \"\", null, null]", + "[\"control_command_event\", \"command_id\", 2, \"uuid\", true, \"\", \"\", null, null]", + "[\"control_command_event\", \"event_id\", 1, \"bigint\", true, \"\", \"\", \"nextval('control_command_event_event_id_seq'::regclass)\", null]", + "[\"control_command_event\", \"event_type\", 3, \"character varying(32)\", true, \"\", \"\", null, \"default\"]", + "[\"control_command_event\", \"occurred_at\", 4, \"timestamp with time zone\", true, \"\", \"\", \"clock_timestamp()\", null]", + "[\"control_command_event\", \"reason_code\", 6, \"character varying(64)\", true, \"\", \"\", null, \"default\"]", + "[\"control_command_event\", \"request_id\", 7, \"character varying(64)\", false, \"\", \"\", null, \"default\"]", + "[\"device\", \"device_id\", 1, \"integer\", true, \"\", \"\", \"nextval('device_device_id_seq'::regclass)\", null]", + "[\"device\", \"modbus_unit_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"device\", \"name\", 2, \"character varying(255)\", true, \"\", \"\", null, \"default\"]", + "[\"device\", \"status\", 4, \"character varying(50)\", false, \"\", \"\", \"'active'::character varying\", \"default\"]", + "[\"deviceparametermapping\", \"active\", 7, \"boolean\", false, \"\", \"\", \"true\", null]", + "[\"deviceparametermapping\", \"device_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"deviceparametermapping\", \"function_code\", 5, \"smallint\", true, \"\", \"\", null, null]", + "[\"deviceparametermapping\", \"mapping_id\", 1, \"integer\", true, \"\", \"\", \"nextval('deviceparametermapping_mapping_id_seq'::regclass)\", null]", + "[\"deviceparametermapping\", \"modbus_register\", 4, \"integer\", true, \"\", \"\", null, null]", + "[\"deviceparametermapping\", \"parameter_type_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"deviceparametermapping\", \"register_type\", 6, \"character varying(50)\", true, \"\", \"\", null, \"default\"]", + "[\"field\", \"description\", 3, \"text\", false, \"\", \"\", null, \"default\"]", + "[\"field\", \"field_id\", 1, \"integer\", true, \"\", \"\", \"nextval('field_field_id_seq'::regclass)\", null]", + "[\"field\", \"name\", 2, \"character varying(255)\", true, \"\", \"\", null, \"default\"]", + "[\"forecast_job\", \"completed_at\", 15, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"forecast_job\", \"horizon_seconds\", 8, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"interval_seconds\", 7, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"job_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"lease_expires_at\", 12, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"missed_intervals\", 13, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"owner_role\", 6, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"forecast_job\", \"owner_user_id\", 5, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"reason_code\", 16, \"character varying(64)\", false, \"\", \"\", null, \"default\"]", + "[\"forecast_job\", \"run_id\", 17, \"uuid\", false, \"\", \"\", null, null]", + "[\"forecast_job\", \"schedule_id\", 2, \"uuid\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"schedule_revision\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"scheduled_for\", 10, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"started_at\", 11, \"timestamp with time zone\", true, \"\", \"\", \"clock_timestamp()\", null]", + "[\"forecast_job\", \"status\", 14, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"forecast_job\", \"step_seconds\", 9, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_job\", \"wellhead_id\", 4, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_schedule\", \"enabled\", 5, \"boolean\", true, \"\", \"\", \"false\", null]", + "[\"forecast_schedule\", \"horizon_seconds\", 7, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_schedule\", \"interval_seconds\", 6, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_schedule\", \"next_due_at\", 10, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"forecast_schedule\", \"owner_role\", 4, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"forecast_schedule\", \"owner_user_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_schedule\", \"reason\", 11, \"character varying(500)\", true, \"\", \"\", null, \"default\"]", + "[\"forecast_schedule\", \"revision\", 9, \"integer\", true, \"\", \"\", null, null]", + "[\"forecast_schedule\", \"schedule_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"forecast_schedule\", \"step_seconds\", 8, \"integer\", true, \"\", \"\", \"60\", null]", + "[\"forecast_schedule\", \"updated_at\", 12, \"timestamp with time zone\", true, \"\", \"\", \"clock_timestamp()\", null]", + "[\"forecast_schedule\", \"wellhead_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"location\", \"address\", 4, \"text\", false, \"\", \"\", null, \"default\"]", + "[\"location\", \"field_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"location\", \"latitude\", 5, \"double precision\", false, \"\", \"\", null, null]", + "[\"location\", \"location_id\", 1, \"integer\", true, \"\", \"\", \"nextval('location_location_id_seq'::regclass)\", null]", + "[\"location\", \"longitude\", 6, \"double precision\", false, \"\", \"\", null, null]", + "[\"location\", \"name\", 3, \"character varying(255)\", true, \"\", \"\", null, \"default\"]", + "[\"model_parameter_change\", \"change_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"model_parameter_change\", \"created_at\", 12, \"timestamp with time zone\", true, \"\", \"\", \"clock_timestamp()\", null]", + "[\"model_parameter_change\", \"created_by\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"model_parameter_change\", \"expected_override_id\", 10, \"integer\", false, \"\", \"\", null, null]", + "[\"model_parameter_change\", \"expires_at\", 13, \"timestamp with time zone\", true, \"\", \"\", \"(clock_timestamp() + '24:00:00'::interval)\", null]", + "[\"model_parameter_change\", \"idempotency_key\", 4, \"uuid\", true, \"\", \"\", null, null]", + "[\"model_parameter_change\", \"model_version\", 5, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"model_parameter_change\", \"parameter_key\", 6, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"model_parameter_change\", \"published_override_id\", 15, \"integer\", false, \"\", \"\", null, null]", + "[\"model_parameter_change\", \"reason\", 9, \"character varying(500)\", true, \"\", \"\", null, \"default\"]", + "[\"model_parameter_change\", \"result_at\", 14, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"model_parameter_change\", \"status\", 11, \"text\", true, \"\", \"\", \"'draft'::text\", \"default\"]", + "[\"model_parameter_change\", \"unit\", 8, \"text\", false, \"\", \"\", null, \"default\"]", + "[\"model_parameter_change\", \"value\", 7, \"double precision\", false, \"\", \"\", null, null]", + "[\"model_parameter_change\", \"wellhead_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"mv_2min_pressure_trends\", \"avg_value\", 9, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_pressure_trends\", \"bucket_time\", 1, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"mv_2min_pressure_trends\", \"canonical_unit\", 8, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_pressure_trends\", \"field_name\", 5, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_pressure_trends\", \"location_name\", 4, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_pressure_trends\", \"max_value\", 11, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_pressure_trends\", \"min_value\", 10, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_pressure_trends\", \"parameter_code\", 6, \"character varying(100)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_pressure_trends\", \"parameter_display_name\", 7, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_pressure_trends\", \"reading_count\", 12, \"bigint\", false, \"\", \"\", null, null]", + "[\"mv_2min_pressure_trends\", \"wellhead_id\", 2, \"integer\", false, \"\", \"\", null, null]", + "[\"mv_2min_pressure_trends\", \"wellhead_name\", 3, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_temp_flow_trends\", \"avg_value\", 9, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_temp_flow_trends\", \"bucket_time\", 1, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"mv_2min_temp_flow_trends\", \"canonical_unit\", 8, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_temp_flow_trends\", \"field_name\", 5, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_temp_flow_trends\", \"location_name\", 4, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_temp_flow_trends\", \"max_value\", 11, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_temp_flow_trends\", \"min_value\", 10, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_temp_flow_trends\", \"parameter_code\", 6, \"character varying(100)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_temp_flow_trends\", \"parameter_display_name\", 7, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_temp_flow_trends\", \"reading_count\", 12, \"bigint\", false, \"\", \"\", null, null]", + "[\"mv_2min_temp_flow_trends\", \"wellhead_id\", 2, \"integer\", false, \"\", \"\", null, null]", + "[\"mv_2min_temp_flow_trends\", \"wellhead_name\", 3, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_water_cut_gor_trends\", \"avg_value\", 9, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_water_cut_gor_trends\", \"bucket_time\", 1, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"mv_2min_water_cut_gor_trends\", \"canonical_unit\", 8, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_water_cut_gor_trends\", \"field_name\", 5, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_water_cut_gor_trends\", \"location_name\", 4, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_water_cut_gor_trends\", \"max_value\", 11, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_water_cut_gor_trends\", \"min_value\", 10, \"double precision\", false, \"\", \"\", null, null]", + "[\"mv_2min_water_cut_gor_trends\", \"parameter_code\", 6, \"character varying(100)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_water_cut_gor_trends\", \"parameter_display_name\", 7, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_2min_water_cut_gor_trends\", \"reading_count\", 12, \"bigint\", false, \"\", \"\", null, null]", + "[\"mv_2min_water_cut_gor_trends\", \"wellhead_id\", 2, \"integer\", false, \"\", \"\", null, null]", + "[\"mv_2min_water_cut_gor_trends\", \"wellhead_name\", 3, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_5min_alarm_counts\", \"alarms_triggered\", 7, \"bigint\", false, \"\", \"\", null, null]", + "[\"mv_5min_alarm_counts\", \"bucket_time\", 1, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"mv_5min_alarm_counts\", \"location_name\", 4, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_5min_alarm_counts\", \"parameter_display_name\", 5, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_5min_alarm_counts\", \"severity_level\", 6, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"mv_5min_alarm_counts\", \"wellhead_id\", 2, \"integer\", false, \"\", \"\", null, null]", + "[\"mv_5min_alarm_counts\", \"wellhead_name\", 3, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"parameterreading\", \"inserted_at\", 7, \"timestamp with time zone\", false, \"\", \"\", \"now()\", null]", + "[\"parameterreading\", \"mapping_id\", 5, \"integer\", true, \"\", \"\", null, null]", + "[\"parameterreading\", \"model_version\", 9, \"model_version_text\", false, \"\", \"\", null, \"default\"]", + "[\"parameterreading\", \"parameter_reading_id\", 1, \"bigint\", true, \"\", \"\", \"nextval('parameterreading_parameter_reading_id_seq'::regclass)\", null]", + "[\"parameterreading\", \"parameter_type_id\", 4, \"integer\", true, \"\", \"\", null, null]", + "[\"parameterreading\", \"raw_value\", 6, \"double precision\", true, \"\", \"\", null, null]", + "[\"parameterreading\", \"source_kind\", 8, \"text\", true, \"\", \"\", \"'synthetic_random_simulator'::text\", \"default\"]", + "[\"parameterreading\", \"timestamp_utc\", 2, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"parameterreading\", \"wellhead_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"parametertype\", \"canonical_unit\", 4, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"parametertype\", \"code\", 2, \"character varying(100)\", true, \"\", \"\", null, \"default\"]", + "[\"parametertype\", \"data_type\", 5, \"character varying(50)\", true, \"\", \"\", null, \"default\"]", + "[\"parametertype\", \"display_name\", 3, \"character varying(255)\", true, \"\", \"\", null, \"default\"]", + "[\"parametertype\", \"normal_max\", 7, \"double precision\", false, \"\", \"\", null, null]", + "[\"parametertype\", \"normal_min\", 6, \"double precision\", false, \"\", \"\", null, null]", + "[\"parametertype\", \"parameter_type_id\", 1, \"integer\", true, \"\", \"\", \"nextval('parametertype_parameter_type_id_seq'::regclass)\", null]", + "[\"prediction_point\", \"alarm_risk\", 9, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"prediction_point\", \"elapsed_seconds\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"flow_rate_bpd\", 6, \"double precision\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"forecast_at\", 2, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"run_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"triggered_rule_ids\", 10, \"jsonb\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"tubing_pressure_psi\", 5, \"double precision\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"values_json\", 4, \"jsonb\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"water_cut_percent\", 8, \"double precision\", true, \"\", \"\", null, null]", + "[\"prediction_point\", \"wellhead_temperature_f\", 7, \"double precision\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"alarm_rules\", 13, \"jsonb\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"base_simulated_at\", 7, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"base_state\", 10, \"jsonb\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"confidence_reasons\", 15, \"jsonb\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"confidence_score\", 14, \"double precision\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"control_values\", 12, \"jsonb\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"created_at\", 2, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"horizon_seconds\", 8, \"integer\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"model_version\", 5, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"prediction_run\", \"parameter_fingerprint\", 6, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"prediction_run\", \"parameter_values\", 11, \"jsonb\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"requested_by_user_id\", 4, \"integer\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"run_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"step_seconds\", 9, \"integer\", true, \"\", \"\", null, null]", + "[\"prediction_run\", \"wellhead_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"alarm_risk\", 10, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"scenario_point\", \"baseline_alarm_risk\", 11, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"scenario_point\", \"baseline_values_json\", 9, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"elapsed_seconds\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"flow_rate_bpd\", 6, \"double precision\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"forecast_at\", 2, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"run_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"triggered_rule_ids\", 12, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"tubing_pressure_psi\", 5, \"double precision\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"values_json\", 4, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"water_cut_percent\", 8, \"double precision\", true, \"\", \"\", null, null]", + "[\"scenario_point\", \"wellhead_temperature_f\", 7, \"double precision\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"alarm_rules\", 15, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"base_simulated_at\", 8, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"base_state\", 11, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"confidence_reasons\", 17, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"confidence_score\", 16, \"double precision\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"control_values\", 13, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"created_at\", 2, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"horizon_seconds\", 9, \"integer\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"model_version\", 6, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"scenario_run\", \"name\", 5, \"character varying(120)\", true, \"\", \"\", null, \"default\"]", + "[\"scenario_run\", \"parameter_fingerprint\", 7, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"scenario_run\", \"parameter_values\", 12, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"requested_by_user_id\", 4, \"integer\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"run_id\", 1, \"uuid\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"scheduled_controls\", 14, \"jsonb\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"step_seconds\", 10, \"integer\", true, \"\", \"\", null, null]", + "[\"scenario_run\", \"wellhead_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"twin_recovery_event\", \"details\", 8, \"jsonb\", true, \"\", \"\", \"'{}'::jsonb\", null]", + "[\"twin_recovery_event\", \"event_id\", 1, \"bigint\", true, \"\", \"\", \"nextval('twin_recovery_event_event_id_seq'::regclass)\", null]", + "[\"twin_recovery_event\", \"event_type\", 4, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"twin_recovery_event\", \"gap_seconds\", 7, \"double precision\", false, \"\", \"\", null, null]", + "[\"twin_recovery_event\", \"model_version\", 5, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"twin_recovery_event\", \"occurred_at\", 2, \"timestamp with time zone\", true, \"\", \"\", \"now()\", null]", + "[\"twin_recovery_event\", \"snapshot_simulated_at\", 6, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"twin_recovery_event\", \"wellhead_id\", 3, \"integer\", false, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"blockage_factor\", 15, \"double precision\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"casing_pressure_psi\", 10, \"double precision\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"choke_position_percent\", 14, \"double precision\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"corrosion_factor\", 16, \"double precision\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"flow_rate_bpd\", 11, \"double precision\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"inserted_at\", 18, \"timestamp with time zone\", true, \"\", \"\", \"now()\", null]", + "[\"twin_state_snapshot\", \"model_version\", 4, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"twin_state_snapshot\", \"parameter_fingerprint\", 5, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"twin_state_snapshot\", \"simulated_at\", 2, \"timestamp with time zone\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"skipped_ticks\", 8, \"bigint\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"snapshot_id\", 1, \"bigint\", true, \"\", \"\", \"nextval('twin_state_snapshot_snapshot_id_seq'::regclass)\", null]", + "[\"twin_state_snapshot\", \"state\", 17, \"jsonb\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"status\", 6, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"twin_state_snapshot\", \"tick_index\", 7, \"bigint\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"tubing_pressure_psi\", 9, \"double precision\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"water_cut_percent\", 13, \"double precision\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"wellhead_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"twin_state_snapshot\", \"wellhead_temperature_f\", 12, \"double precision\", true, \"\", \"\", null, null]", + "[\"users\", \"bio\", 7, \"text\", false, \"\", \"\", null, \"default\"]", + "[\"users\", \"created_at\", 10, \"timestamp with time zone\", true, \"\", \"\", \"now()\", null]", + "[\"users\", \"deleted\", 12, \"boolean\", false, \"\", \"\", \"false\", null]", + "[\"users\", \"deleted_at\", 13, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"users\", \"email\", 2, \"character varying(256)\", true, \"\", \"\", null, \"default\"]", + "[\"users\", \"encrypted_password\", 14, \"character varying(256)\", true, \"\", \"\", null, \"default\"]", + "[\"users\", \"first_name\", 3, \"character varying(256)\", false, \"\", \"\", null, \"default\"]", + "[\"users\", \"id\", 1, \"integer\", true, \"\", \"\", \"nextval('users_id_seq'::regclass)\", null]", + "[\"users\", \"image\", 8, \"character varying(256)\", false, \"\", \"\", null, \"default\"]", + "[\"users\", \"last_name\", 4, \"character varying(256)\", false, \"\", \"\", null, \"default\"]", + "[\"users\", \"last_signed_in\", 15, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"users\", \"phone_number\", 6, \"character varying(256)\", false, \"\", \"\", null, \"default\"]", + "[\"users\", \"role\", 9, \"role\", false, \"\", \"\", \"'USER'::role\", null]", + "[\"users\", \"session_version\", 17, \"integer\", true, \"\", \"\", \"0\", null]", + "[\"users\", \"status\", 11, \"status\", false, \"\", \"\", \"'ACTIVE'::status\", null]", + "[\"users\", \"updated_at\", 16, \"timestamp with time zone\", false, \"\", \"\", \"now()\", null]", + "[\"users\", \"user_name\", 5, \"character varying(256)\", true, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"alarm_rule_id\", 14, \"integer\", false, \"\", \"\", null, null]", + "[\"v_active_alarms\", \"canonical_unit\", 11, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"event_id\", 1, \"bigint\", false, \"\", \"\", null, null]", + "[\"v_active_alarms\", \"field_name\", 8, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"location_name\", 7, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"operator\", 12, \"character varying(10)\", false, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"parameter_code\", 9, \"character varying(100)\", false, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"parameter_display_name\", 10, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"severity_level\", 3, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"v_active_alarms\", \"threshold_value\", 13, \"double precision\", false, \"\", \"\", null, null]", + "[\"v_active_alarms\", \"triggered_at\", 2, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"v_active_alarms\", \"triggered_value\", 4, \"double precision\", false, \"\", \"\", null, null]", + "[\"v_active_alarms\", \"wellhead_id\", 5, \"integer\", false, \"\", \"\", null, null]", + "[\"v_active_alarms\", \"wellhead_name\", 6, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"canonical_unit\", 13, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"data_type\", 14, \"character varying(50)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"field_id\", 8, \"integer\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"field_name\", 9, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"location_id\", 6, \"integer\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"location_name\", 7, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"normal_max\", 16, \"double precision\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"normal_min\", 15, \"double precision\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"parameter_code\", 11, \"character varying(100)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"parameter_display_name\", 12, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"parameter_type_id\", 10, \"integer\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"raw_value\", 2, \"double precision\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"timestamp_utc\", 1, \"timestamp with time zone\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"wellhead_id\", 3, \"integer\", false, \"\", \"\", null, null]", + "[\"v_wellhead_parameter_readings\", \"wellhead_name\", 4, \"character varying(255)\", false, \"\", \"\", null, \"default\"]", + "[\"v_wellhead_parameter_readings\", \"wellhead_type\", 5, \"character varying(100)\", false, \"\", \"\", null, \"default\"]", + "[\"wellhead\", \"device_id\", 3, \"integer\", true, \"\", \"\", null, null]", + "[\"wellhead\", \"location_id\", 2, \"integer\", true, \"\", \"\", null, null]", + "[\"wellhead\", \"name\", 4, \"character varying(255)\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead\", \"status\", 6, \"character varying(50)\", false, \"\", \"\", \"'active'::character varying\", \"default\"]", + "[\"wellhead\", \"type\", 5, \"character varying(100)\", false, \"\", \"\", null, \"default\"]", + "[\"wellhead\", \"wellhead_id\", 1, \"integer\", true, \"\", \"\", \"nextval('wellhead_wellhead_id_seq'::regclass)\", null]", + "[\"wellhead_control_state\", \"applied_at\", 12, \"timestamp with time zone\", true, \"\", \"\", \"clock_timestamp()\", null]", + "[\"wellhead_control_state\", \"applied_command_id\", 11, \"uuid\", false, \"\", \"\", null, null]", + "[\"wellhead_control_state\", \"choke_position_percent\", 5, \"double precision\", true, \"\", \"\", null, null]", + "[\"wellhead_control_state\", \"emergency_shutdown\", 10, \"boolean\", true, \"\", \"\", null, null]", + "[\"wellhead_control_state\", \"master_valve_open\", 6, \"boolean\", true, \"\", \"\", null, null]", + "[\"wellhead_control_state\", \"model_version\", 3, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead_control_state\", \"parameter_fingerprint\", 4, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead_control_state\", \"pump_on\", 9, \"boolean\", true, \"\", \"\", null, null]", + "[\"wellhead_control_state\", \"revision\", 2, \"integer\", true, \"\", \"\", \"0\", null]", + "[\"wellhead_control_state\", \"swab_valve_open\", 8, \"boolean\", true, \"\", \"\", null, null]", + "[\"wellhead_control_state\", \"wellhead_id\", 1, \"integer\", true, \"\", \"\", null, null]", + "[\"wellhead_control_state\", \"wing_valve_open\", 7, \"boolean\", true, \"\", \"\", null, null]", + "[\"wellhead_model_parameter\", \"changed_by\", 8, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead_model_parameter\", \"model_version\", 3, \"model_version_text\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead_model_parameter\", \"parameter_key\", 4, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead_model_parameter\", \"parameter_override_id\", 1, \"integer\", true, \"\", \"\", \"nextval('wellhead_model_parameter_parameter_override_id_seq'::regclass)\", null]", + "[\"wellhead_model_parameter\", \"reason\", 9, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead_model_parameter\", \"source\", 7, \"text\", true, \"\", \"\", null, \"default\"]", + "[\"wellhead_model_parameter\", \"unit\", 6, \"text\", false, \"\", \"\", null, \"default\"]", + "[\"wellhead_model_parameter\", \"valid_from\", 10, \"timestamp with time zone\", true, \"\", \"\", \"now()\", null]", + "[\"wellhead_model_parameter\", \"value\", 5, \"double precision\", false, \"\", \"\", null, null]", + "[\"wellhead_model_parameter\", \"wellhead_id\", 2, \"integer\", true, \"\", \"\", null, null]" + ], + "compression": [ + "[\"alarmevent\", \"alarm_rule_id\", 2, null, null, null]", + "[\"alarmevent\", \"triggered_at\", null, 1, false, true]", + "[\"alarmevent\", \"wellhead_id\", 1, null, null, null]", + "[\"parameterreading\", \"parameter_type_id\", 2, null, null, null]", + "[\"parameterreading\", \"timestamp_utc\", null, 1, false, true]", + "[\"parameterreading\", \"wellhead_id\", 1, null, null, null]", + "[\"prediction_point\", \"forecast_at\", null, 1, false, true]", + "[\"prediction_point\", \"run_id\", 1, null, null, null]", + "[\"scenario_point\", \"forecast_at\", null, 1, false, true]", + "[\"scenario_point\", \"run_id\", 1, null, null, null]", + "[\"twin_state_snapshot\", \"simulated_at\", null, 1, false, true]", + "[\"twin_state_snapshot\", \"wellhead_id\", 1, null, null, null]" + ], + "constraints": [ + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_acknowledged_at_check\", \"CHECK (isfinite(acknowledged_at))\", true]", + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_action_id_fkey\", \"FOREIGN KEY (action_id) REFERENCES alarm_action(action_id)\", true]", + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_action_id_key\", \"UNIQUE (action_id)\", true]", + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_event_id_check\", \"CHECK ((event_id > 0))\", true]", + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_pkey\", \"PRIMARY KEY (event_id, triggered_at)\", true]", + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_triggered_at_check\", \"CHECK (isfinite(triggered_at))\", true]", + "[\"alarm_action\", \"alarm_action_action_check\", \"CHECK ((action = ANY (ARRAY['acknowledge'::text, 'shelve'::text, 'unshelve'::text])))\", true]", + "[\"alarm_action\", \"alarm_action_actor_role_check\", \"CHECK ((actor_role = 'OPERATIONS'::text))\", true]", + "[\"alarm_action\", \"alarm_action_actor_user_id_request_key_key\", \"UNIQUE (actor_user_id, request_key)\", true]", + "[\"alarm_action\", \"alarm_action_input_check\", \"CHECK ((jsonb_typeof(input) = 'object'::text))\", true]", + "[\"alarm_action\", \"alarm_action_input_hash_check\", \"CHECK (((input_hash)::text ~ '^[0-9a-f]{64}$'::text))\", true]", + "[\"alarm_action\", \"alarm_action_occurred_at_check\", \"CHECK (isfinite(occurred_at))\", true]", + "[\"alarm_action\", \"alarm_action_pkey\", \"PRIMARY KEY (action_id)\", true]", + "[\"alarm_action\", \"alarm_action_result_check\", \"CHECK ((jsonb_typeof(result) = 'object'::text))\", true]", + "[\"alarm_shelf\", \"alarm_shelf_action_id_fkey\", \"FOREIGN KEY (action_id) REFERENCES alarm_action(action_id)\", true]", + "[\"alarm_shelf\", \"alarm_shelf_alarm_rule_id_fkey\", \"FOREIGN KEY (alarm_rule_id) REFERENCES alarmrule(alarm_rule_id) ON DELETE CASCADE\", true]", + "[\"alarm_shelf\", \"alarm_shelf_check\", \"CHECK (((shelved_until IS NULL) OR (isfinite(shelved_until) AND (shelved_until >= (updated_at + '00:05:00'::interval)) AND (shelved_until <= (updated_at + '08:00:00'::interval)))))\", true]", + "[\"alarm_shelf\", \"alarm_shelf_pkey\", \"PRIMARY KEY (wellhead_id, alarm_rule_id)\", true]", + "[\"alarm_shelf\", \"alarm_shelf_revision_check\", \"CHECK ((revision > 0))\", true]", + "[\"alarm_shelf\", \"alarm_shelf_updated_at_check\", \"CHECK (isfinite(updated_at))\", true]", + "[\"alarm_shelf\", \"alarm_shelf_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id) ON DELETE CASCADE\", true]", + "[\"alarmevent\", \"alarmevent_alarm_rule_id_fkey\", \"FOREIGN KEY (alarm_rule_id) REFERENCES alarmrule(alarm_rule_id) ON DELETE CASCADE\", true]", + "[\"alarmevent\", \"alarmevent_pkey\", \"PRIMARY KEY (event_id, triggered_at)\", true]", + "[\"alarmevent\", \"alarmevent_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id) ON DELETE CASCADE\", true]", + "[\"alarmrule\", \"alarmrule_parameter_type_id_fkey\", \"FOREIGN KEY (parameter_type_id) REFERENCES parametertype(parameter_type_id) ON DELETE CASCADE\", true]", + "[\"alarmrule\", \"alarmrule_pkey\", \"PRIMARY KEY (alarm_rule_id)\", true]", + "[\"alarmrulestate\", \"alarmrulestate_alarm_rule_id_fkey\", \"FOREIGN KEY (alarm_rule_id) REFERENCES alarmrule(alarm_rule_id) ON DELETE CASCADE\", true]", + "[\"alarmrulestate\", \"alarmrulestate_pkey\", \"PRIMARY KEY (wellhead_id, alarm_rule_id)\", true]", + "[\"alarmrulestate\", \"alarmrulestate_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id) ON DELETE CASCADE\", true]", + "[\"auditevent\", \"audit_event_outcome\", \"CHECK (((outcome)::text = ANY ((ARRAY['success'::character varying, 'failure'::character varying])::text[])))\", true]", + "[\"auditevent\", \"auditevent_actor_user_id_fkey\", \"FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL\", true]", + "[\"auditevent\", \"auditevent_pkey\", \"PRIMARY KEY (audit_event_id, occurred_at)\", true]", + "[\"control_command\", \"control_command_activated_by_fkey\", \"FOREIGN KEY (activated_by) REFERENCES users(id)\", true]", + "[\"control_command\", \"control_command_check\", \"CHECK ((num_nonnulls(choke_position_percent, master_valve_open, wing_valve_open, pump_on, emergency_shutdown) > 0))\", true]", + "[\"control_command\", \"control_command_check1\", \"CHECK (((emergency_shutdown IS NULL) OR (num_nonnulls(choke_position_percent, master_valve_open, wing_valve_open, pump_on) = 0)))\", true]", + "[\"control_command\", \"control_command_check2\", \"CHECK (((status <> 'queued'::text) OR ((queued_at IS NOT NULL) AND (activated_by IS NOT NULL) AND (activated_by = created_by) AND (activated_role IS NOT NULL) AND (activated_session_version IS NOT NULL))))\", true]", + "[\"control_command\", \"control_command_check3\", \"CHECK (((status = ANY (ARRAY['applied'::text, 'rejected'::text, 'expired'::text, 'cancelled'::text])) = (result_at IS NOT NULL)))\", true]", + "[\"control_command\", \"control_command_check4\", \"CHECK (((status <> 'applied'::text) OR ((result_revision IS NOT NULL) AND (result_revision = (expected_revision + 1)))))\", true]", + "[\"control_command\", \"control_command_choke_position_percent_check\", \"CHECK (((choke_position_percent >= (0)::double precision) AND (choke_position_percent <= (100)::double precision)))\", true]", + "[\"control_command\", \"control_command_created_by_fkey\", \"FOREIGN KEY (created_by) REFERENCES users(id)\", true]", + "[\"control_command\", \"control_command_created_by_idempotency_key_key\", \"UNIQUE (created_by, idempotency_key)\", true]", + "[\"control_command\", \"control_command_expected_revision_check\", \"CHECK ((expected_revision >= 0))\", true]", + "[\"control_command\", \"control_command_pkey\", \"PRIMARY KEY (command_id)\", true]", + "[\"control_command\", \"control_command_reason_check\", \"CHECK ((length(TRIM(BOTH FROM reason)) > 0))\", true]", + "[\"control_command\", \"control_command_status_check\", \"CHECK ((status = ANY (ARRAY['draft'::text, 'queued'::text, 'applied'::text, 'rejected'::text, 'expired'::text, 'cancelled'::text])))\", true]", + "[\"control_command\", \"control_command_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"control_command_event\", \"control_command_event_actor_user_id_fkey\", \"FOREIGN KEY (actor_user_id) REFERENCES users(id)\", true]", + "[\"control_command_event\", \"control_command_event_command_id_fkey\", \"FOREIGN KEY (command_id) REFERENCES control_command(command_id)\", true]", + "[\"control_command_event\", \"control_command_event_event_type_check\", \"CHECK (((event_type)::text = ANY ((ARRAY['draft'::character varying, 'queued'::character varying, 'applied'::character varying, 'rejected'::character varying, 'expired'::character varying, 'cancelled'::character varying])::text[])))\", true]", + "[\"control_command_event\", \"control_command_event_pkey\", \"PRIMARY KEY (event_id)\", true]", + "[\"device\", \"device_name_key\", \"UNIQUE (name)\", true]", + "[\"device\", \"device_pkey\", \"PRIMARY KEY (device_id)\", true]", + "[\"deviceparametermapping\", \"deviceparametermapping_device_id_fkey\", \"FOREIGN KEY (device_id) REFERENCES device(device_id) ON DELETE CASCADE\", true]", + "[\"deviceparametermapping\", \"deviceparametermapping_device_id_modbus_register_key\", \"UNIQUE (device_id, modbus_register)\", true]", + "[\"deviceparametermapping\", \"deviceparametermapping_parameter_type_id_fkey\", \"FOREIGN KEY (parameter_type_id) REFERENCES parametertype(parameter_type_id) ON DELETE CASCADE\", true]", + "[\"deviceparametermapping\", \"deviceparametermapping_pkey\", \"PRIMARY KEY (mapping_id)\", true]", + "[\"field\", \"field_name_key\", \"UNIQUE (name)\", true]", + "[\"field\", \"field_pkey\", \"PRIMARY KEY (field_id)\", true]", + "[\"forecast_job\", \"forecast_job_check\", \"CHECK (((status = 'running'::text) = (completed_at IS NULL)))\", true]", + "[\"forecast_job\", \"forecast_job_check1\", \"CHECK (((status = 'complete'::text) = (run_id IS NOT NULL)))\", true]", + "[\"forecast_job\", \"forecast_job_check2\", \"CHECK ((lease_expires_at > started_at))\", true]", + "[\"forecast_job\", \"forecast_job_horizon_seconds_check\", \"CHECK ((((horizon_seconds >= 300) AND (horizon_seconds <= 1800)) AND ((horizon_seconds % 60) = 0)))\", true]", + "[\"forecast_job\", \"forecast_job_interval_seconds_check\", \"CHECK ((((interval_seconds >= 300) AND (interval_seconds <= 86400)) AND ((interval_seconds % 60) = 0)))\", true]", + "[\"forecast_job\", \"forecast_job_missed_intervals_check\", \"CHECK ((missed_intervals >= 0))\", true]", + "[\"forecast_job\", \"forecast_job_owner_role_check\", \"CHECK ((owner_role = 'ADMIN'::text))\", true]", + "[\"forecast_job\", \"forecast_job_owner_user_id_fkey\", \"FOREIGN KEY (owner_user_id) REFERENCES users(id)\", true]", + "[\"forecast_job\", \"forecast_job_pkey\", \"PRIMARY KEY (job_id)\", true]", + "[\"forecast_job\", \"forecast_job_run_id_fkey\", \"FOREIGN KEY (run_id) REFERENCES prediction_run(run_id)\", true]", + "[\"forecast_job\", \"forecast_job_schedule_id_fkey\", \"FOREIGN KEY (schedule_id) REFERENCES forecast_schedule(schedule_id)\", true]", + "[\"forecast_job\", \"forecast_job_schedule_id_scheduled_for_key\", \"UNIQUE (schedule_id, scheduled_for)\", true]", + "[\"forecast_job\", \"forecast_job_schedule_revision_check\", \"CHECK ((schedule_revision > 0))\", true]", + "[\"forecast_job\", \"forecast_job_status_check\", \"CHECK ((status = ANY (ARRAY['running'::text, 'complete'::text, 'skipped'::text, 'failed'::text])))\", true]", + "[\"forecast_job\", \"forecast_job_step_seconds_check\", \"CHECK ((step_seconds = 60))\", true]", + "[\"forecast_job\", \"forecast_job_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"forecast_schedule\", \"forecast_schedule_check\", \"CHECK ((enabled = (next_due_at IS NOT NULL)))\", true]", + "[\"forecast_schedule\", \"forecast_schedule_horizon_seconds_check\", \"CHECK ((((horizon_seconds >= 300) AND (horizon_seconds <= 1800)) AND ((horizon_seconds % 60) = 0)))\", true]", + "[\"forecast_schedule\", \"forecast_schedule_interval_seconds_check\", \"CHECK ((((interval_seconds >= 300) AND (interval_seconds <= 86400)) AND ((interval_seconds % 60) = 0)))\", true]", + "[\"forecast_schedule\", \"forecast_schedule_owner_role_check\", \"CHECK ((owner_role = 'ADMIN'::text))\", true]", + "[\"forecast_schedule\", \"forecast_schedule_owner_user_id_fkey\", \"FOREIGN KEY (owner_user_id) REFERENCES users(id)\", true]", + "[\"forecast_schedule\", \"forecast_schedule_pkey\", \"PRIMARY KEY (schedule_id)\", true]", + "[\"forecast_schedule\", \"forecast_schedule_reason_check\", \"CHECK ((length(TRIM(BOTH FROM reason)) > 0))\", true]", + "[\"forecast_schedule\", \"forecast_schedule_revision_check\", \"CHECK ((revision > 0))\", true]", + "[\"forecast_schedule\", \"forecast_schedule_step_seconds_check\", \"CHECK ((step_seconds = 60))\", true]", + "[\"forecast_schedule\", \"forecast_schedule_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"forecast_schedule\", \"forecast_schedule_wellhead_id_key\", \"UNIQUE (wellhead_id)\", true]", + "[\"location\", \"location_field_id_fkey\", \"FOREIGN KEY (field_id) REFERENCES field(field_id) ON DELETE CASCADE\", true]", + "[\"location\", \"location_pkey\", \"PRIMARY KEY (location_id)\", true]", + "[\"model_parameter_change\", \"model_parameter_change_check\", \"CHECK (((value IS NULL) = (unit IS NULL)))\", true]", + "[\"model_parameter_change\", \"model_parameter_change_check1\", \"CHECK (((status <> 'draft'::text) = (result_at IS NOT NULL)))\", true]", + "[\"model_parameter_change\", \"model_parameter_change_check2\", \"CHECK (((status = 'published'::text) = (published_override_id IS NOT NULL)))\", true]", + "[\"model_parameter_change\", \"model_parameter_change_created_by_fkey\", \"FOREIGN KEY (created_by) REFERENCES users(id)\", true]", + "[\"model_parameter_change\", \"model_parameter_change_created_by_idempotency_key_key\", \"UNIQUE (created_by, idempotency_key)\", true]", + "[\"model_parameter_change\", \"model_parameter_change_expected_override_id_fkey\", \"FOREIGN KEY (expected_override_id) REFERENCES wellhead_model_parameter(parameter_override_id)\", true]", + "[\"model_parameter_change\", \"model_parameter_change_pkey\", \"PRIMARY KEY (change_id)\", true]", + "[\"model_parameter_change\", \"model_parameter_change_published_override_id_fkey\", \"FOREIGN KEY (published_override_id) REFERENCES wellhead_model_parameter(parameter_override_id)\", true]", + "[\"model_parameter_change\", \"model_parameter_change_reason_check\", \"CHECK ((length(TRIM(BOTH FROM reason)) > 0))\", true]", + "[\"model_parameter_change\", \"model_parameter_change_status_check\", \"CHECK ((status = ANY (ARRAY['draft'::text, 'published'::text, 'cancelled'::text, 'expired'::text])))\", true]", + "[\"model_parameter_change\", \"model_parameter_change_value_check\", \"CHECK (((value IS NULL) OR ((value > '-Infinity'::double precision) AND (value < 'Infinity'::double precision))))\", true]", + "[\"model_parameter_change\", \"model_parameter_change_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"parameterreading\", \"parameter_reading_source_kind_check\", \"CHECK ((source_kind = ANY (ARRAY['synthetic_random_simulator'::text, 'synthetic_reduced_order_model'::text])))\", true]", + "[\"parameterreading\", \"parameterreading_mapping_id_fkey\", \"FOREIGN KEY (mapping_id) REFERENCES deviceparametermapping(mapping_id) ON DELETE CASCADE\", true]", + "[\"parameterreading\", \"parameterreading_parameter_type_id_fkey\", \"FOREIGN KEY (parameter_type_id) REFERENCES parametertype(parameter_type_id) ON DELETE CASCADE\", true]", + "[\"parameterreading\", \"parameterreading_pkey\", \"PRIMARY KEY (parameter_reading_id, timestamp_utc)\", true]", + "[\"parameterreading\", \"parameterreading_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id) ON DELETE CASCADE\", true]", + "[\"parametertype\", \"parametertype_code_key\", \"UNIQUE (code)\", true]", + "[\"parametertype\", \"parametertype_pkey\", \"PRIMARY KEY (parameter_type_id)\", true]", + "[\"prediction_point\", \"prediction_point_alarm_risk_check\", \"CHECK ((alarm_risk = ANY (ARRAY['none'::text, 'warning'::text, 'critical'::text, 'unknown'::text])))\", true]", + "[\"prediction_point\", \"prediction_point_elapsed_seconds_check\", \"CHECK ((elapsed_seconds > 0))\", true]", + "[\"prediction_point\", \"prediction_point_flow_rate_bpd_check\", \"CHECK ((flow_rate_bpd >= (0)::double precision))\", true]", + "[\"prediction_point\", \"prediction_point_pkey\", \"PRIMARY KEY (run_id, forecast_at)\", true]", + "[\"prediction_point\", \"prediction_point_run_id_fkey\", \"FOREIGN KEY (run_id) REFERENCES prediction_run(run_id)\", true]", + "[\"prediction_point\", \"prediction_point_tubing_pressure_psi_check\", \"CHECK ((tubing_pressure_psi >= (0)::double precision))\", true]", + "[\"prediction_point\", \"prediction_point_water_cut_percent_check\", \"CHECK (((water_cut_percent >= (0)::double precision) AND (water_cut_percent <= (100)::double precision)))\", true]", + "[\"prediction_run\", \"prediction_run_confidence_score_check\", \"CHECK (((confidence_score >= (0)::double precision) AND (confidence_score <= (1)::double precision)))\", true]", + "[\"prediction_run\", \"prediction_run_horizon_seconds_check\", \"CHECK (((horizon_seconds >= 300) AND (horizon_seconds <= 7200)))\", true]", + "[\"prediction_run\", \"prediction_run_pkey\", \"PRIMARY KEY (run_id)\", true]", + "[\"prediction_run\", \"prediction_run_requested_by_user_id_fkey\", \"FOREIGN KEY (requested_by_user_id) REFERENCES users(id)\", true]", + "[\"prediction_run\", \"prediction_run_step_seconds_check\", \"CHECK (((step_seconds >= 5) AND (step_seconds <= 300)))\", true]", + "[\"prediction_run\", \"prediction_run_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"scenario_point\", \"scenario_point_alarm_risk_check\", \"CHECK ((alarm_risk = ANY (ARRAY['none'::text, 'warning'::text, 'critical'::text, 'unknown'::text])))\", true]", + "[\"scenario_point\", \"scenario_point_baseline_alarm_risk_check\", \"CHECK ((baseline_alarm_risk = ANY (ARRAY['none'::text, 'warning'::text, 'critical'::text, 'unknown'::text])))\", true]", + "[\"scenario_point\", \"scenario_point_elapsed_seconds_check\", \"CHECK ((elapsed_seconds > 0))\", true]", + "[\"scenario_point\", \"scenario_point_flow_rate_bpd_check\", \"CHECK ((flow_rate_bpd >= (0)::double precision))\", true]", + "[\"scenario_point\", \"scenario_point_pkey\", \"PRIMARY KEY (run_id, forecast_at)\", true]", + "[\"scenario_point\", \"scenario_point_run_id_fkey\", \"FOREIGN KEY (run_id) REFERENCES scenario_run(run_id)\", true]", + "[\"scenario_point\", \"scenario_point_tubing_pressure_psi_check\", \"CHECK ((tubing_pressure_psi >= (0)::double precision))\", true]", + "[\"scenario_point\", \"scenario_point_water_cut_percent_check\", \"CHECK (((water_cut_percent >= (0)::double precision) AND (water_cut_percent <= (100)::double precision)))\", true]", + "[\"scenario_run\", \"scenario_run_confidence_score_check\", \"CHECK (((confidence_score >= (0)::double precision) AND (confidence_score <= (1)::double precision)))\", true]", + "[\"scenario_run\", \"scenario_run_horizon_seconds_check\", \"CHECK (((horizon_seconds >= 300) AND (horizon_seconds <= 7200)))\", true]", + "[\"scenario_run\", \"scenario_run_name_check\", \"CHECK ((length(TRIM(BOTH FROM name)) > 0))\", true]", + "[\"scenario_run\", \"scenario_run_pkey\", \"PRIMARY KEY (run_id)\", true]", + "[\"scenario_run\", \"scenario_run_requested_by_user_id_fkey\", \"FOREIGN KEY (requested_by_user_id) REFERENCES users(id)\", true]", + "[\"scenario_run\", \"scenario_run_step_seconds_check\", \"CHECK (((step_seconds >= 5) AND (step_seconds <= 300)))\", true]", + "[\"scenario_run\", \"scenario_run_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"twin_recovery_event\", \"twin_recovery_event_event_type_check\", \"CHECK ((event_type = ANY (ARRAY['snapshot_restored'::text, 'cold_start_no_snapshot'::text, 'snapshot_incompatible'::text, 'clock_jump_forward'::text, 'clock_jump_backward'::text, 'active_controls_restored'::text])))\", true]", + "[\"twin_recovery_event\", \"twin_recovery_event_gap_seconds_check\", \"CHECK (((gap_seconds IS NULL) OR (gap_seconds >= (0)::double precision)))\", true]", + "[\"twin_recovery_event\", \"twin_recovery_event_pkey\", \"PRIMARY KEY (event_id, occurred_at)\", true]", + "[\"twin_recovery_event\", \"twin_recovery_event_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"twin_state_snapshot\", \"twin_state_snapshot_pkey\", \"PRIMARY KEY (snapshot_id, simulated_at)\", true]", + "[\"twin_state_snapshot\", \"twin_state_snapshot_skipped_ticks_check\", \"CHECK ((skipped_ticks >= 0))\", true]", + "[\"twin_state_snapshot\", \"twin_state_snapshot_status_check\", \"CHECK ((status = 'running'::text))\", true]", + "[\"twin_state_snapshot\", \"twin_state_snapshot_tick_index_check\", \"CHECK ((tick_index >= 0))\", true]", + "[\"twin_state_snapshot\", \"twin_state_snapshot_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"users\", \"users_email_key\", \"UNIQUE (email)\", true]", + "[\"users\", \"users_phone_number_key\", \"UNIQUE (phone_number)\", true]", + "[\"users\", \"users_pkey\", \"PRIMARY KEY (id)\", true]", + "[\"users\", \"users_user_name_key\", \"UNIQUE (user_name)\", true]", + "[\"wellhead\", \"wellhead_device_id_fkey\", \"FOREIGN KEY (device_id) REFERENCES device(device_id) ON DELETE CASCADE\", true]", + "[\"wellhead\", \"wellhead_device_id_key\", \"UNIQUE (device_id)\", true]", + "[\"wellhead\", \"wellhead_location_id_fkey\", \"FOREIGN KEY (location_id) REFERENCES location(location_id) ON DELETE CASCADE\", true]", + "[\"wellhead\", \"wellhead_name_key\", \"UNIQUE (name)\", true]", + "[\"wellhead\", \"wellhead_pkey\", \"PRIMARY KEY (wellhead_id)\", true]", + "[\"wellhead_control_state\", \"wellhead_control_state_applied_command_id_fkey\", \"FOREIGN KEY (applied_command_id) REFERENCES control_command(command_id)\", true]", + "[\"wellhead_control_state\", \"wellhead_control_state_choke_position_percent_check\", \"CHECK (((choke_position_percent >= (0)::double precision) AND (choke_position_percent <= (100)::double precision)))\", true]", + "[\"wellhead_control_state\", \"wellhead_control_state_pkey\", \"PRIMARY KEY (wellhead_id)\", true]", + "[\"wellhead_control_state\", \"wellhead_control_state_revision_check\", \"CHECK ((revision >= 0))\", true]", + "[\"wellhead_control_state\", \"wellhead_control_state_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_changed_by_check\", \"CHECK ((length(TRIM(BOTH FROM changed_by)) > 0))\", true]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_check\", \"CHECK (((value IS NULL) = (unit IS NULL)))\", true]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_pkey\", \"PRIMARY KEY (parameter_override_id)\", true]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_reason_check\", \"CHECK ((length(TRIM(BOTH FROM reason)) > 0))\", true]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_source_check\", \"CHECK ((length(TRIM(BOTH FROM source)) > 0))\", true]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_wellhead_id_fkey\", \"FOREIGN KEY (wellhead_id) REFERENCES wellhead(wellhead_id)\", true]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_wellhead_id_model_version_paramete_key\", \"UNIQUE (wellhead_id, model_version, parameter_key, valid_from)\", true]" + ], + "dimensions": [ + "[\"alarmevent\", 1, \"triggered_at\", \"timestamp with time zone\", \"Time\", \"7 days\", null, null, null]", + "[\"auditevent\", 1, \"occurred_at\", \"timestamp with time zone\", \"Time\", \"7 days\", null, null, null]", + "[\"parameterreading\", 1, \"timestamp_utc\", \"timestamp with time zone\", \"Time\", \"7 days\", null, null, null]", + "[\"prediction_point\", 1, \"forecast_at\", \"timestamp with time zone\", \"Time\", \"7 days\", null, null, null]", + "[\"scenario_point\", 1, \"forecast_at\", \"timestamp with time zone\", \"Time\", \"7 days\", null, null, null]", + "[\"twin_recovery_event\", 1, \"occurred_at\", \"timestamp with time zone\", \"Time\", \"7 days\", null, null, null]", + "[\"twin_state_snapshot\", 1, \"simulated_at\", \"timestamp with time zone\", \"Time\", \"7 days\", null, null, null]" + ], + "functions": [ + "[\"control_actor_authority\", \"actor_id integer\", \"CREATE OR REPLACE FUNCTION public.control_actor_authority(actor_id integer)\\n RETURNS TABLE(role text, status text, deleted boolean, session_version integer)\\n LANGUAGE sql\\n SECURITY DEFINER\\n SET search_path TO 'pg_catalog', 'public', 'pg_temp'\\nAS $function$ SELECT role::text, status::text, deleted, session_version\\n FROM public.users WHERE id = actor_id FOR SHARE $function$\\n\", false]", + "[\"enforce_control_lifecycle\", \"\", \"CREATE OR REPLACE FUNCTION public.enforce_control_lifecycle()\\n RETURNS trigger\\n LANGUAGE plpgsql\\n SET search_path TO 'pg_catalog', 'public', 'pg_temp'\\nAS $function$\\nBEGIN\\n IF (to_jsonb(NEW) - ARRAY['status','expires_at','queued_at','activated_by',\\n 'activated_role','activated_session_version','result_at','result_revision','result_reason'])\\n IS DISTINCT FROM\\n (to_jsonb(OLD) - ARRAY['status','expires_at','queued_at','activated_by',\\n 'activated_role','activated_session_version','result_at','result_revision','result_reason']) THEN\\n RAISE EXCEPTION 'Control proposals are immutable';\\n END IF;\\n IF NOT ((OLD.status = 'draft' AND NEW.status IN ('queued','cancelled','expired'))\\n OR (OLD.status = 'queued' AND NEW.status IN ('applied','rejected','expired','cancelled'))) THEN\\n RAISE EXCEPTION 'Invalid control lifecycle transition';\\n END IF;\\n IF OLD.status = 'queued' AND\\n ROW(NEW.queued_at, NEW.expires_at, NEW.activated_by, NEW.activated_role, NEW.activated_session_version)\\n IS DISTINCT FROM\\n ROW(OLD.queued_at, OLD.expires_at, OLD.activated_by, OLD.activated_role, OLD.activated_session_version) THEN\\n RAISE EXCEPTION 'Activation authority is immutable';\\n END IF;\\n RETURN NEW;\\nEND $function$\\n\", true]", + "[\"enforce_parameter_change_lifecycle\", \"\", \"CREATE OR REPLACE FUNCTION public.enforce_parameter_change_lifecycle()\\n RETURNS trigger\\n LANGUAGE plpgsql\\n SET search_path TO 'pg_catalog', 'public', 'pg_temp'\\nAS $function$\\nBEGIN\\n IF (to_jsonb(NEW) - ARRAY['status','result_at','published_override_id'])\\n IS DISTINCT FROM (to_jsonb(OLD) - ARRAY['status','result_at','published_override_id'])\\n OR OLD.status <> 'draft' OR NEW.status NOT IN ('published','cancelled','expired') THEN\\n RAISE EXCEPTION 'Invalid parameter change lifecycle transition';\\n END IF;\\n RETURN NEW;\\nEND $function$\\n\", true]", + "[\"evaluate_wellhead_alarms\", \"\", \"CREATE OR REPLACE FUNCTION public.evaluate_wellhead_alarms()\\n RETURNS trigger\\n LANGUAGE plpgsql\\nAS $function$\\nBEGIN\\n -- One evaluation per (wellhead, rule) per statement. The ingestion service\\n -- writes one reading per wellhead and parameter per batch, so the latest\\n -- row in the batch is that evaluation. A batch carrying several samples for\\n -- the same parameter counts as one evaluation, which delays a state change\\n -- rather than causing a wrong one.\\n WITH latest AS (\\n SELECT DISTINCT ON (i.wellhead_id, i.parameter_type_id)\\n i.wellhead_id,\\n i.parameter_type_id,\\n i.parameter_reading_id,\\n i.timestamp_utc,\\n i.raw_value\\n FROM inserted i\\n ORDER BY i.wellhead_id, i.parameter_type_id, i.timestamp_utc DESC\\n ),\\n evaluated AS (\\n SELECT l.wellhead_id,\\n r.alarm_rule_id,\\n r.severity_level,\\n r.trigger_after,\\n r.clear_after,\\n l.parameter_reading_id,\\n l.timestamp_utc,\\n l.raw_value,\\n CASE r.operator\\n WHEN '>' THEN l.raw_value > r.threshold_value\\n WHEN '>=' THEN l.raw_value >= r.threshold_value\\n WHEN '<' THEN l.raw_value < r.threshold_value\\n WHEN '<=' THEN l.raw_value <= r.threshold_value\\n WHEN '==' THEN l.raw_value = r.threshold_value\\n ELSE FALSE\\n END AS breaching\\n FROM latest l\\n JOIN alarmRule r\\n ON r.parameter_type_id = l.parameter_type_id\\n AND r.active = TRUE\\n ),\\n counted AS (\\n INSERT INTO alarmRuleState AS s (\\n wellhead_id, alarm_rule_id,\\n consecutive_breaches, consecutive_clears, last_evaluated_at\\n )\\n SELECT e.wellhead_id,\\n e.alarm_rule_id,\\n CASE WHEN e.breaching THEN 1 ELSE 0 END,\\n CASE WHEN e.breaching THEN 0 ELSE 1 END,\\n e.timestamp_utc\\n FROM evaluated e\\n ON CONFLICT (wellhead_id, alarm_rule_id) DO UPDATE\\n SET consecutive_breaches = CASE\\n WHEN EXCLUDED.consecutive_breaches > 0\\n THEN s.consecutive_breaches + 1 ELSE 0 END,\\n consecutive_clears = CASE\\n WHEN EXCLUDED.consecutive_clears > 0\\n THEN s.consecutive_clears + 1 ELSE 0 END,\\n last_evaluated_at = EXCLUDED.last_evaluated_at\\n RETURNING s.wellhead_id, s.alarm_rule_id,\\n s.consecutive_breaches, s.consecutive_clears\\n ),\\n decided AS (\\n SELECT e.*, c.consecutive_breaches, c.consecutive_clears\\n FROM evaluated e\\n JOIN counted c\\n ON c.wellhead_id = e.wellhead_id\\n AND c.alarm_rule_id = e.alarm_rule_id\\n ),\\n opened AS (\\n INSERT INTO alarmEvent (\\n alarm_rule_id, parameter_reading_id, wellhead_id, timestamp_utc,\\n triggered_at, severity_level, triggered_value\\n )\\n SELECT d.alarm_rule_id, d.parameter_reading_id, d.wellhead_id,\\n d.timestamp_utc, d.timestamp_utc, d.severity_level, d.raw_value\\n FROM decided d\\n WHERE d.breaching\\n AND d.consecutive_breaches >= d.trigger_after\\n AND NOT EXISTS (\\n SELECT 1 FROM alarmEvent a\\n WHERE a.wellhead_id = d.wellhead_id\\n AND a.alarm_rule_id = d.alarm_rule_id\\n AND a.cleared_at IS NULL\\n )\\n RETURNING 1\\n )\\n UPDATE alarmEvent a\\n SET cleared_at = d.timestamp_utc\\n FROM decided d\\n WHERE a.wellhead_id = d.wellhead_id\\n AND a.alarm_rule_id = d.alarm_rule_id\\n AND a.cleared_at IS NULL\\n AND NOT d.breaching\\n AND d.consecutive_clears >= d.clear_after;\\n\\n RETURN NULL;\\nEND;\\n$function$\\n\", true]", + "[\"lock_control_state\", \"well_id integer\", \"CREATE OR REPLACE FUNCTION public.lock_control_state(well_id integer)\\n RETURNS void\\n LANGUAGE sql\\n SECURITY DEFINER\\n SET search_path TO 'pg_catalog', 'public', 'pg_temp'\\nAS $function$ SELECT 1 FROM public.wellhead_control_state WHERE wellhead_id = well_id FOR UPDATE $function$\\n\", false]" + ], + "hypertables": [ + "[\"alarmevent\", 1, true]", + "[\"auditevent\", 1, false]", + "[\"parameterreading\", 1, true]", + "[\"prediction_point\", 1, true]", + "[\"scenario_point\", 1, true]", + "[\"twin_recovery_event\", 1, false]", + "[\"twin_state_snapshot\", 1, true]" + ], + "indexes": [ + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_action_id_key\", \"CREATE UNIQUE INDEX alarm_acknowledgement_action_id_key ON public.alarm_acknowledgement USING btree (action_id)\", true, true, false]", + "[\"alarm_acknowledgement\", \"alarm_acknowledgement_pkey\", \"CREATE UNIQUE INDEX alarm_acknowledgement_pkey ON public.alarm_acknowledgement USING btree (event_id, triggered_at)\", true, true, false]", + "[\"alarm_action\", \"alarm_action_actor_user_id_request_key_key\", \"CREATE UNIQUE INDEX alarm_action_actor_user_id_request_key_key ON public.alarm_action USING btree (actor_user_id, request_key)\", true, true, false]", + "[\"alarm_action\", \"alarm_action_pkey\", \"CREATE UNIQUE INDEX alarm_action_pkey ON public.alarm_action USING btree (action_id)\", true, true, false]", + "[\"alarm_action\", \"ix_alarm_action_target\", \"CREATE INDEX ix_alarm_action_target ON public.alarm_action USING btree (wellhead_id, alarm_rule_id, occurred_at DESC, action_id DESC)\", true, true, false]", + "[\"alarm_shelf\", \"alarm_shelf_pkey\", \"CREATE UNIQUE INDEX alarm_shelf_pkey ON public.alarm_shelf USING btree (wellhead_id, alarm_rule_id)\", true, true, false]", + "[\"alarm_shelf\", \"ix_alarm_shelf_deadline\", \"CREATE INDEX ix_alarm_shelf_deadline ON public.alarm_shelf USING btree (shelved_until) WHERE (shelved_until IS NOT NULL)\", true, true, false]", + "[\"alarmevent\", \"alarmevent_pkey\", \"CREATE UNIQUE INDEX alarmevent_pkey ON public.alarmevent USING btree (event_id, triggered_at)\", true, true, false]", + "[\"alarmevent\", \"alarmevent_triggered_at_idx\", \"CREATE INDEX alarmevent_triggered_at_idx ON public.alarmevent USING btree (triggered_at DESC)\", true, true, false]", + "[\"alarmevent\", \"ix_alarm_event_open\", \"CREATE INDEX ix_alarm_event_open ON public.alarmevent USING btree (wellhead_id, alarm_rule_id) WHERE (cleared_at IS NULL)\", true, true, false]", + "[\"alarmrule\", \"alarmrule_pkey\", \"CREATE UNIQUE INDEX alarmrule_pkey ON public.alarmrule USING btree (alarm_rule_id)\", true, true, false]", + "[\"alarmrule\", \"uq_alarm_rule_definition\", \"CREATE UNIQUE INDEX uq_alarm_rule_definition ON public.alarmrule USING btree (parameter_type_id, severity_level, operator, threshold_value)\", true, true, false]", + "[\"alarmrulestate\", \"alarmrulestate_pkey\", \"CREATE UNIQUE INDEX alarmrulestate_pkey ON public.alarmrulestate USING btree (wellhead_id, alarm_rule_id)\", true, true, false]", + "[\"auditevent\", \"auditevent_occurred_at_idx\", \"CREATE INDEX auditevent_occurred_at_idx ON public.auditevent USING btree (occurred_at DESC)\", true, true, false]", + "[\"auditevent\", \"auditevent_pkey\", \"CREATE UNIQUE INDEX auditevent_pkey ON public.auditevent USING btree (audit_event_id, occurred_at)\", true, true, false]", + "[\"auditevent\", \"ix_audit_event_action\", \"CREATE INDEX ix_audit_event_action ON public.auditevent USING btree (action, occurred_at DESC)\", true, true, false]", + "[\"auditevent\", \"ix_audit_event_actor\", \"CREATE INDEX ix_audit_event_actor ON public.auditevent USING btree (actor_user_id, occurred_at DESC)\", true, true, false]", + "[\"auditevent\", \"ix_audit_event_identifier\", \"CREATE INDEX ix_audit_event_identifier ON public.auditevent USING btree (actor_identifier, occurred_at DESC) WHERE (actor_identifier IS NOT NULL)\", true, true, false]", + "[\"auditevent\", \"ix_audit_event_subject\", \"CREATE INDEX ix_audit_event_subject ON public.auditevent USING btree (subject_type, subject_id, occurred_at DESC)\", true, true, false]", + "[\"control_command\", \"control_command_created_by_idempotency_key_key\", \"CREATE UNIQUE INDEX control_command_created_by_idempotency_key_key ON public.control_command USING btree (created_by, idempotency_key)\", true, true, false]", + "[\"control_command\", \"control_command_pkey\", \"CREATE UNIQUE INDEX control_command_pkey ON public.control_command USING btree (command_id)\", true, true, false]", + "[\"control_command\", \"ix_control_inbox\", \"CREATE INDEX ix_control_inbox ON public.control_command USING btree (queued_at, command_id) WHERE (status = 'queued'::text)\", true, true, false]", + "[\"control_command\", \"ix_control_one_draft\", \"CREATE UNIQUE INDEX ix_control_one_draft ON public.control_command USING btree (created_by, wellhead_id) WHERE (status = 'draft'::text)\", true, true, false]", + "[\"control_command\", \"ix_control_one_queued\", \"CREATE UNIQUE INDEX ix_control_one_queued ON public.control_command USING btree (wellhead_id) WHERE (status = 'queued'::text)\", true, true, false]", + "[\"control_command_event\", \"control_command_event_pkey\", \"CREATE UNIQUE INDEX control_command_event_pkey ON public.control_command_event USING btree (event_id)\", true, true, false]", + "[\"control_command_event\", \"ix_control_event_command\", \"CREATE INDEX ix_control_event_command ON public.control_command_event USING btree (command_id, event_id)\", true, true, false]", + "[\"device\", \"device_name_key\", \"CREATE UNIQUE INDEX device_name_key ON public.device USING btree (name)\", true, true, false]", + "[\"device\", \"device_pkey\", \"CREATE UNIQUE INDEX device_pkey ON public.device USING btree (device_id)\", true, true, false]", + "[\"deviceparametermapping\", \"deviceparametermapping_device_id_modbus_register_key\", \"CREATE UNIQUE INDEX deviceparametermapping_device_id_modbus_register_key ON public.deviceparametermapping USING btree (device_id, modbus_register)\", true, true, false]", + "[\"deviceparametermapping\", \"deviceparametermapping_pkey\", \"CREATE UNIQUE INDEX deviceparametermapping_pkey ON public.deviceparametermapping USING btree (mapping_id)\", true, true, false]", + "[\"field\", \"field_name_key\", \"CREATE UNIQUE INDEX field_name_key ON public.field USING btree (name)\", true, true, false]", + "[\"field\", \"field_pkey\", \"CREATE UNIQUE INDEX field_pkey ON public.field USING btree (field_id)\", true, true, false]", + "[\"forecast_job\", \"forecast_job_pkey\", \"CREATE UNIQUE INDEX forecast_job_pkey ON public.forecast_job USING btree (job_id)\", true, true, false]", + "[\"forecast_job\", \"forecast_job_schedule_id_scheduled_for_key\", \"CREATE UNIQUE INDEX forecast_job_schedule_id_scheduled_for_key ON public.forecast_job USING btree (schedule_id, scheduled_for)\", true, true, false]", + "[\"forecast_job\", \"ix_forecast_job_expiry\", \"CREATE INDEX ix_forecast_job_expiry ON public.forecast_job USING btree (lease_expires_at) WHERE (status = 'running'::text)\", true, true, false]", + "[\"forecast_job\", \"ix_forecast_job_recent\", \"CREATE INDEX ix_forecast_job_recent ON public.forecast_job USING btree (wellhead_id, started_at DESC)\", true, true, false]", + "[\"forecast_job\", \"ix_forecast_job_running\", \"CREATE UNIQUE INDEX ix_forecast_job_running ON public.forecast_job USING btree (schedule_id) WHERE (status = 'running'::text)\", true, true, false]", + "[\"forecast_schedule\", \"forecast_schedule_pkey\", \"CREATE UNIQUE INDEX forecast_schedule_pkey ON public.forecast_schedule USING btree (schedule_id)\", true, true, false]", + "[\"forecast_schedule\", \"forecast_schedule_wellhead_id_key\", \"CREATE UNIQUE INDEX forecast_schedule_wellhead_id_key ON public.forecast_schedule USING btree (wellhead_id)\", true, true, false]", + "[\"forecast_schedule\", \"ix_forecast_schedule_due\", \"CREATE INDEX ix_forecast_schedule_due ON public.forecast_schedule USING btree (next_due_at, schedule_id) WHERE enabled\", true, true, false]", + "[\"location\", \"location_pkey\", \"CREATE UNIQUE INDEX location_pkey ON public.location USING btree (location_id)\", true, true, false]", + "[\"model_parameter_change\", \"ix_parameter_one_draft\", \"CREATE UNIQUE INDEX ix_parameter_one_draft ON public.model_parameter_change USING btree (created_by, wellhead_id, parameter_key) WHERE (status = 'draft'::text)\", true, true, false]", + "[\"model_parameter_change\", \"model_parameter_change_created_by_idempotency_key_key\", \"CREATE UNIQUE INDEX model_parameter_change_created_by_idempotency_key_key ON public.model_parameter_change USING btree (created_by, idempotency_key)\", true, true, false]", + "[\"model_parameter_change\", \"model_parameter_change_pkey\", \"CREATE UNIQUE INDEX model_parameter_change_pkey ON public.model_parameter_change USING btree (change_id)\", true, true, false]", + "[\"parameterreading\", \"ix_parameter_reading_mapping_time\", \"CREATE INDEX ix_parameter_reading_mapping_time ON public.parameterreading USING btree (mapping_id, timestamp_utc DESC, parameter_reading_id DESC)\", true, true, false]", + "[\"parameterreading\", \"ix_parameter_reading_type_time\", \"CREATE INDEX ix_parameter_reading_type_time ON public.parameterreading USING btree (parameter_type_id, timestamp_utc DESC)\", true, true, false]", + "[\"parameterreading\", \"ix_parameter_reading_wellhead_time\", \"CREATE INDEX ix_parameter_reading_wellhead_time ON public.parameterreading USING btree (wellhead_id, timestamp_utc DESC)\", true, true, false]", + "[\"parameterreading\", \"parameterreading_pkey\", \"CREATE UNIQUE INDEX parameterreading_pkey ON public.parameterreading USING btree (parameter_reading_id, timestamp_utc)\", true, true, false]", + "[\"parameterreading\", \"parameterreading_timestamp_utc_idx\", \"CREATE INDEX parameterreading_timestamp_utc_idx ON public.parameterreading USING btree (timestamp_utc DESC)\", true, true, false]", + "[\"parametertype\", \"parametertype_code_key\", \"CREATE UNIQUE INDEX parametertype_code_key ON public.parametertype USING btree (code)\", true, true, false]", + "[\"parametertype\", \"parametertype_pkey\", \"CREATE UNIQUE INDEX parametertype_pkey ON public.parametertype USING btree (parameter_type_id)\", true, true, false]", + "[\"prediction_point\", \"prediction_point_forecast_at_idx\", \"CREATE INDEX prediction_point_forecast_at_idx ON public.prediction_point USING btree (forecast_at DESC)\", true, true, false]", + "[\"prediction_point\", \"prediction_point_pkey\", \"CREATE UNIQUE INDEX prediction_point_pkey ON public.prediction_point USING btree (run_id, forecast_at)\", true, true, false]", + "[\"prediction_run\", \"ix_prediction_run_well_created\", \"CREATE INDEX ix_prediction_run_well_created ON public.prediction_run USING btree (wellhead_id, created_at DESC)\", true, true, false]", + "[\"prediction_run\", \"prediction_run_pkey\", \"CREATE UNIQUE INDEX prediction_run_pkey ON public.prediction_run USING btree (run_id)\", true, true, false]", + "[\"scenario_point\", \"scenario_point_forecast_at_idx\", \"CREATE INDEX scenario_point_forecast_at_idx ON public.scenario_point USING btree (forecast_at DESC)\", true, true, false]", + "[\"scenario_point\", \"scenario_point_pkey\", \"CREATE UNIQUE INDEX scenario_point_pkey ON public.scenario_point USING btree (run_id, forecast_at)\", true, true, false]", + "[\"scenario_run\", \"ix_scenario_run_well_created\", \"CREATE INDEX ix_scenario_run_well_created ON public.scenario_run USING btree (wellhead_id, created_at DESC)\", true, true, false]", + "[\"scenario_run\", \"scenario_run_pkey\", \"CREATE UNIQUE INDEX scenario_run_pkey ON public.scenario_run USING btree (run_id)\", true, true, false]", + "[\"twin_recovery_event\", \"ix_twin_recovery_event_well_time\", \"CREATE INDEX ix_twin_recovery_event_well_time ON public.twin_recovery_event USING btree (wellhead_id, occurred_at DESC)\", true, true, false]", + "[\"twin_recovery_event\", \"twin_recovery_event_occurred_at_idx\", \"CREATE INDEX twin_recovery_event_occurred_at_idx ON public.twin_recovery_event USING btree (occurred_at DESC)\", true, true, false]", + "[\"twin_recovery_event\", \"twin_recovery_event_pkey\", \"CREATE UNIQUE INDEX twin_recovery_event_pkey ON public.twin_recovery_event USING btree (event_id, occurred_at)\", true, true, false]", + "[\"twin_state_snapshot\", \"ix_twin_state_snapshot_well_time\", \"CREATE INDEX ix_twin_state_snapshot_well_time ON public.twin_state_snapshot USING btree (wellhead_id, simulated_at DESC)\", true, true, false]", + "[\"twin_state_snapshot\", \"twin_state_snapshot_pkey\", \"CREATE UNIQUE INDEX twin_state_snapshot_pkey ON public.twin_state_snapshot USING btree (snapshot_id, simulated_at)\", true, true, false]", + "[\"twin_state_snapshot\", \"twin_state_snapshot_simulated_at_idx\", \"CREATE INDEX twin_state_snapshot_simulated_at_idx ON public.twin_state_snapshot USING btree (simulated_at DESC)\", true, true, false]", + "[\"users\", \"users_email_key\", \"CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)\", true, true, false]", + "[\"users\", \"users_phone_number_key\", \"CREATE UNIQUE INDEX users_phone_number_key ON public.users USING btree (phone_number)\", true, true, false]", + "[\"users\", \"users_pkey\", \"CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)\", true, true, false]", + "[\"users\", \"users_user_name_key\", \"CREATE UNIQUE INDEX users_user_name_key ON public.users USING btree (user_name)\", true, true, false]", + "[\"wellhead\", \"wellhead_device_id_key\", \"CREATE UNIQUE INDEX wellhead_device_id_key ON public.wellhead USING btree (device_id)\", true, true, false]", + "[\"wellhead\", \"wellhead_name_key\", \"CREATE UNIQUE INDEX wellhead_name_key ON public.wellhead USING btree (name)\", true, true, false]", + "[\"wellhead\", \"wellhead_pkey\", \"CREATE UNIQUE INDEX wellhead_pkey ON public.wellhead USING btree (wellhead_id)\", true, true, false]", + "[\"wellhead_control_state\", \"wellhead_control_state_pkey\", \"CREATE UNIQUE INDEX wellhead_control_state_pkey ON public.wellhead_control_state USING btree (wellhead_id)\", true, true, false]", + "[\"wellhead_model_parameter\", \"ix_wellhead_model_parameter_current\", \"CREATE INDEX ix_wellhead_model_parameter_current ON public.wellhead_model_parameter USING btree (wellhead_id, model_version, parameter_key, valid_from DESC)\", true, true, false]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_pkey\", \"CREATE UNIQUE INDEX wellhead_model_parameter_pkey ON public.wellhead_model_parameter USING btree (parameter_override_id)\", true, true, false]", + "[\"wellhead_model_parameter\", \"wellhead_model_parameter_wellhead_id_model_version_paramete_key\", \"CREATE UNIQUE INDEX wellhead_model_parameter_wellhead_id_model_version_paramete_key ON public.wellhead_model_parameter USING btree (wellhead_id, model_version, parameter_key, valid_from)\", true, true, false]" + ], + "jobs": [ + "[\"alarmevent\", \"_timescaledb_functions\", \"policy_compression\", \"12:00:00\", \"00:00:00\", -1, \"01:00:00\", true, false, {\"compress_after\": \"7 days\"}, \"_timescaledb_functions\", \"policy_compression_check\"]", + "[\"alarmevent\", \"_timescaledb_functions\", \"policy_retention\", \"1 day\", \"00:05:00\", -1, \"00:05:00\", true, false, {\"drop_after\": \"90 days\"}, \"_timescaledb_functions\", \"policy_retention_check\"]", + "[\"auditevent\", \"_timescaledb_functions\", \"policy_retention\", \"1 day\", \"00:05:00\", -1, \"00:05:00\", true, false, {\"drop_after\": \"90 days\"}, \"_timescaledb_functions\", \"policy_retention_check\"]", + "[\"mv_2min_pressure_trends\", \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate\", \"00:02:00\", \"00:00:00\", -1, \"00:02:00\", true, false, {\"end_offset\": \"00:02:00\", \"start_offset\": \"1 day\"}, \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate_check\"]", + "[\"mv_2min_temp_flow_trends\", \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate\", \"00:02:00\", \"00:00:00\", -1, \"00:02:00\", true, false, {\"end_offset\": \"00:02:00\", \"start_offset\": \"1 day\"}, \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate_check\"]", + "[\"mv_2min_water_cut_gor_trends\", \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate\", \"00:02:00\", \"00:00:00\", -1, \"00:02:00\", true, false, {\"end_offset\": \"00:02:00\", \"start_offset\": \"1 day\"}, \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate_check\"]", + "[\"mv_5min_alarm_counts\", \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate\", \"00:05:00\", \"00:00:00\", -1, \"00:05:00\", true, false, {\"end_offset\": \"00:05:00\", \"start_offset\": \"30 days\"}, \"_timescaledb_functions\", \"policy_refresh_continuous_aggregate_check\"]", + "[\"parameterreading\", \"_timescaledb_functions\", \"policy_compression\", \"12:00:00\", \"00:00:00\", -1, \"01:00:00\", true, false, {\"compress_after\": \"1 day\"}, \"_timescaledb_functions\", \"policy_compression_check\"]", + "[\"parameterreading\", \"_timescaledb_functions\", \"policy_retention\", \"1 day\", \"00:05:00\", -1, \"00:05:00\", true, false, {\"drop_after\": \"7 days\"}, \"_timescaledb_functions\", \"policy_retention_check\"]", + "[\"prediction_point\", \"_timescaledb_functions\", \"policy_compression\", \"12:00:00\", \"00:00:00\", -1, \"01:00:00\", true, false, {\"compress_after\": \"3 days\"}, \"_timescaledb_functions\", \"policy_compression_check\"]", + "[\"prediction_point\", \"_timescaledb_functions\", \"policy_retention\", \"1 day\", \"00:05:00\", -1, \"00:05:00\", true, false, {\"drop_after\": \"30 days\"}, \"_timescaledb_functions\", \"policy_retention_check\"]", + "[\"scenario_point\", \"_timescaledb_functions\", \"policy_compression\", \"12:00:00\", \"00:00:00\", -1, \"01:00:00\", true, false, {\"compress_after\": \"7 days\"}, \"_timescaledb_functions\", \"policy_compression_check\"]", + "[\"scenario_point\", \"_timescaledb_functions\", \"policy_retention\", \"1 day\", \"00:05:00\", -1, \"00:05:00\", true, false, {\"drop_after\": \"90 days\"}, \"_timescaledb_functions\", \"policy_retention_check\"]", + "[\"twin_recovery_event\", \"_timescaledb_functions\", \"policy_retention\", \"1 day\", \"00:05:00\", -1, \"00:05:00\", true, false, {\"drop_after\": \"2 years\"}, \"_timescaledb_functions\", \"policy_retention_check\"]", + "[\"twin_state_snapshot\", \"_timescaledb_functions\", \"policy_compression\", \"12:00:00\", \"00:00:00\", -1, \"01:00:00\", true, false, {\"compress_after\": \"7 days\"}, \"_timescaledb_functions\", \"policy_compression_check\"]", + "[\"twin_state_snapshot\", \"_timescaledb_functions\", \"policy_retention\", \"1 day\", \"00:05:00\", -1, \"00:05:00\", true, false, {\"drop_after\": \"90 days\"}, \"_timescaledb_functions\", \"policy_retention_check\"]" + ], + "relations": [ + "[\"alarm_acknowledgement\", \"r\", \"p\", false, false]", + "[\"alarm_action\", \"r\", \"p\", false, false]", + "[\"alarm_shelf\", \"r\", \"p\", false, false]", + "[\"alarmevent\", \"r\", \"p\", false, false]", + "[\"alarmevent_event_id_seq\", \"S\", \"p\", false, false]", + "[\"alarmrule\", \"r\", \"p\", false, false]", + "[\"alarmrule_alarm_rule_id_seq\", \"S\", \"p\", false, false]", + "[\"alarmrulestate\", \"r\", \"p\", false, false]", + "[\"auditevent\", \"r\", \"p\", false, false]", + "[\"auditevent_audit_event_id_seq\", \"S\", \"p\", false, false]", + "[\"control_command\", \"r\", \"p\", false, false]", + "[\"control_command_event\", \"r\", \"p\", false, false]", + "[\"control_command_event_event_id_seq\", \"S\", \"p\", false, false]", + "[\"device\", \"r\", \"p\", false, false]", + "[\"device_device_id_seq\", \"S\", \"p\", false, false]", + "[\"deviceparametermapping\", \"r\", \"p\", false, false]", + "[\"deviceparametermapping_mapping_id_seq\", \"S\", \"p\", false, false]", + "[\"field\", \"r\", \"p\", false, false]", + "[\"field_field_id_seq\", \"S\", \"p\", false, false]", + "[\"forecast_job\", \"r\", \"p\", false, false]", + "[\"forecast_schedule\", \"r\", \"p\", false, false]", + "[\"location\", \"r\", \"p\", false, false]", + "[\"location_location_id_seq\", \"S\", \"p\", false, false]", + "[\"model_parameter_change\", \"r\", \"p\", false, false]", + "[\"mv_2min_pressure_trends\", \"v\", \"p\", false, false]", + "[\"mv_2min_temp_flow_trends\", \"v\", \"p\", false, false]", + "[\"mv_2min_water_cut_gor_trends\", \"v\", \"p\", false, false]", + "[\"mv_5min_alarm_counts\", \"v\", \"p\", false, false]", + "[\"parameterreading\", \"r\", \"p\", false, false]", + "[\"parameterreading_parameter_reading_id_seq\", \"S\", \"p\", false, false]", + "[\"parametertype\", \"r\", \"p\", false, false]", + "[\"parametertype_parameter_type_id_seq\", \"S\", \"p\", false, false]", + "[\"prediction_point\", \"r\", \"p\", false, false]", + "[\"prediction_run\", \"r\", \"p\", false, false]", + "[\"scenario_point\", \"r\", \"p\", false, false]", + "[\"scenario_run\", \"r\", \"p\", false, false]", + "[\"twin_recovery_event\", \"r\", \"p\", false, false]", + "[\"twin_recovery_event_event_id_seq\", \"S\", \"p\", false, false]", + "[\"twin_state_snapshot\", \"r\", \"p\", false, false]", + "[\"twin_state_snapshot_snapshot_id_seq\", \"S\", \"p\", false, false]", + "[\"users\", \"r\", \"p\", false, false]", + "[\"users_id_seq\", \"S\", \"p\", false, false]", + "[\"v_active_alarms\", \"v\", \"p\", false, false]", + "[\"v_wellhead_parameter_readings\", \"v\", \"p\", false, false]", + "[\"wellhead\", \"r\", \"p\", false, false]", + "[\"wellhead_control_state\", \"r\", \"p\", false, false]", + "[\"wellhead_model_parameter\", \"r\", \"p\", false, false]", + "[\"wellhead_model_parameter_parameter_override_id_seq\", \"S\", \"p\", false, false]", + "[\"wellhead_wellhead_id_seq\", \"S\", \"p\", false, false]" + ], + "row_policies": [], + "sequences": [ + "[\"alarmevent_event_id_seq\", \"bigint\", 1, 1, 9223372036854775807, 1, 1, false, \"alarmevent\", \"event_id\"]", + "[\"alarmrule_alarm_rule_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"alarmrule\", \"alarm_rule_id\"]", + "[\"auditevent_audit_event_id_seq\", \"bigint\", 1, 1, 9223372036854775807, 1, 1, false, \"auditevent\", \"audit_event_id\"]", + "[\"control_command_event_event_id_seq\", \"bigint\", 1, 1, 9223372036854775807, 1, 1, false, \"control_command_event\", \"event_id\"]", + "[\"device_device_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"device\", \"device_id\"]", + "[\"deviceparametermapping_mapping_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"deviceparametermapping\", \"mapping_id\"]", + "[\"field_field_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"field\", \"field_id\"]", + "[\"location_location_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"location\", \"location_id\"]", + "[\"parameterreading_parameter_reading_id_seq\", \"bigint\", 1, 1, 9223372036854775807, 1, 1, false, \"parameterreading\", \"parameter_reading_id\"]", + "[\"parametertype_parameter_type_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"parametertype\", \"parameter_type_id\"]", + "[\"twin_recovery_event_event_id_seq\", \"bigint\", 1, 1, 9223372036854775807, 1, 1, false, \"twin_recovery_event\", \"event_id\"]", + "[\"twin_state_snapshot_snapshot_id_seq\", \"bigint\", 1, 1, 9223372036854775807, 1, 1, false, \"twin_state_snapshot\", \"snapshot_id\"]", + "[\"users_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"users\", \"id\"]", + "[\"wellhead_model_parameter_parameter_override_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"wellhead_model_parameter\", \"parameter_override_id\"]", + "[\"wellhead_wellhead_id_seq\", \"integer\", 1, 1, 2147483647, 1, 1, false, \"wellhead\", \"wellhead_id\"]" + ], + "triggers": [ + "[\"control_command\", \"enforce_control_lifecycle\", \"CREATE TRIGGER enforce_control_lifecycle BEFORE UPDATE ON public.control_command FOR EACH ROW EXECUTE FUNCTION enforce_control_lifecycle()\", \"O\"]", + "[\"model_parameter_change\", \"enforce_parameter_change_lifecycle\", \"CREATE TRIGGER enforce_parameter_change_lifecycle BEFORE UPDATE ON public.model_parameter_change FOR EACH ROW EXECUTE FUNCTION enforce_parameter_change_lifecycle()\", \"O\"]", + "[\"parameterreading\", \"evaluate_wellhead_alarms_trigger\", \"CREATE TRIGGER evaluate_wellhead_alarms_trigger AFTER INSERT ON public.parameterreading REFERENCING NEW TABLE AS inserted FOR EACH STATEMENT EXECUTE FUNCTION evaluate_wellhead_alarms()\", \"O\"]" + ], + "types": [ + "[\"model_version_text\", \"d\", \"text\", false, null, [], [\"CHECK ((VALUE ~ '^[0-9]+\\\\.[0-9]+\\\\.[0-9]+$'::text))\"]]", + "[\"role\", \"e\", \"-\", false, null, [\"USER\", \"ADMIN\", \"OPERATIONS\"], []]", + "[\"status\", \"e\", \"-\", false, null, [\"ACTIVE\", \"DISABLED\"], []]" + ], + "views": [ + "[\"v_active_alarms\", \" SELECT ae.event_id,\\n ae.triggered_at,\\n ae.severity_level,\\n ae.triggered_value,\\n wh.wellhead_id,\\n wh.name AS wellhead_name,\\n loc.name AS location_name,\\n f.name AS field_name,\\n pt.code AS parameter_code,\\n pt.display_name AS parameter_display_name,\\n pt.canonical_unit,\\n ar.operator,\\n ar.threshold_value,\\n ar.alarm_rule_id\\n FROM (((((alarmevent ae\\n JOIN wellhead wh ON ((ae.wellhead_id = wh.wellhead_id)))\\n JOIN location loc ON ((wh.location_id = loc.location_id)))\\n JOIN field f ON ((loc.field_id = f.field_id)))\\n JOIN alarmrule ar ON ((ae.alarm_rule_id = ar.alarm_rule_id)))\\n JOIN parametertype pt ON ((ar.parameter_type_id = pt.parameter_type_id)))\\n WHERE (ae.cleared_at IS NULL);\", null]", + "[\"v_wellhead_parameter_readings\", \" SELECT pr.timestamp_utc,\\n pr.raw_value,\\n wh.wellhead_id,\\n wh.name AS wellhead_name,\\n wh.type AS wellhead_type,\\n loc.location_id,\\n loc.name AS location_name,\\n f.field_id,\\n f.name AS field_name,\\n pt.parameter_type_id,\\n pt.code AS parameter_code,\\n pt.display_name AS parameter_display_name,\\n pt.canonical_unit,\\n pt.data_type,\\n pt.normal_min,\\n pt.normal_max\\n FROM ((((parameterreading pr\\n JOIN wellhead wh ON ((pr.wellhead_id = wh.wellhead_id)))\\n JOIN location loc ON ((wh.location_id = loc.location_id)))\\n JOIN field f ON ((loc.field_id = f.field_id)))\\n JOIN parametertype pt ON ((pr.parameter_type_id = pt.parameter_type_id)));\", null]" + ] + }, + "format": 1, + "legacy_checksums": { + "0001_core_schema.sql": "85edf46eafb343c5e0d5b7f5611c6806f5ac622c1e597fa5c521ebe2b35e49a7", + "0002_alarm_evaluation.sql": "1ecab75ed9fe1d8ed36e4f6bd03e3a26a1a34c3b17cd3d8724e572b2780b4c1e", + "0003_views.sql": "ac3727ca0d92897fe03ed42462c4ab7311bcaea959ebbf96e0fdf2fc429d9ace", + "0004_continuous_aggregates.sql": "d8c19b6e182d097a32d4fc62be35d3ea9e2713c50a27b4f2f59ce71c8ced9937", + "0005_retention_and_compression.sql": "18e9ca2bea41fa605995abfb7ce8639bcc391fff33bc0749b981228c07de4f04", + "0006_audit_event.sql": "e1040b36ebf00772e94de4cd16bd5724aca988f5fcb6b03636a314e7d42fc9e8", + "0006_latest_mapping_read_index.sql": "b47e5fccb550fb1d2ad44c58b77a68ab576fb005b1dea1c0ec8d8739fe3ed4c8", + "0007_model_reading_source.sql": "bd6fa2cac750ed0b19035ae24243d19cf75fa7a217dba01bcecccc87576650bb", + "0008_reading_model_version.sql": "8c7593a1cafa29ad504551d7c8c3dcbc591bc72c33ee02c7aceb6fcbeed37014", + "0009_user_session_version.sql": "b0e47eb2f447c60e71191ca4deafcb71c46dc7485188fba372cbf393d8607e6f", + "0010_twin_persistence.sql": "361fdf55c8cd583315fe6b73a5c3923123ea6ce8b77d786bfee95e532f61c1cb", + "0011_forecasts_and_scenarios.sql": "4feb527bb2c78276d9fd68157770cfa21e9b281d92608f88614abd259659cd37", + "0012_live_model_controls.sql": "2309abd9675c8682ae66533bf0e6f599bacfe34bc63047228413a5bec5e8dfd0" + }, + "source_checksum": "d00470916d2d681145ae0f6c94f4db6a4e43fd4fcf84dbd8d2222031e78731f1", + "versions": { + "postgres_major": "16", + "timescaledb": "2.30.2" + } +} diff --git a/data/sql/schema.sql b/data/sql/schema.sql new file mode 100644 index 0000000..11e0ee9 --- /dev/null +++ b/data/sql/schema.sql @@ -0,0 +1,933 @@ +-- Complete current historian schema. Edit this file for structural changes. +-- Run through schema.py, which checks database state before applying any SQL. +-- Existing databases use checked adoption, never replay this installation file. +-- Each statement runs separately for TimescaleDB continuous aggregate support. + +CREATE EXTENSION IF NOT EXISTS timescaledb; + +CREATE TYPE "status" AS ENUM ('ACTIVE', 'DISABLED'); +CREATE TYPE "role" AS ENUM ('USER', 'ADMIN', 'OPERATIONS'); + +-- A domain keeps version validation compatible with compressed readings. +CREATE DOMAIN model_version_text AS TEXT + CHECK (VALUE ~ '^[0-9]+\.[0-9]+\.[0-9]+$'); + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(256) NOT NULL UNIQUE, + first_name VARCHAR(256), + last_name VARCHAR(256), + user_name VARCHAR(256) NOT NULL UNIQUE, + phone_number VARCHAR(256) UNIQUE, + bio TEXT, + image VARCHAR(256), + role "role" DEFAULT 'USER', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status "status" DEFAULT 'ACTIVE', + deleted BOOLEAN DEFAULT FALSE, + deleted_at TIMESTAMPTZ, + encrypted_password VARCHAR(256) NOT NULL, + last_signed_in TIMESTAMPTZ, + updated_at TIMESTAMPTZ DEFAULT NOW(), + session_version INTEGER NOT NULL DEFAULT 0 +); + +-- Asset hierarchy ----------------------------------------------------------- + +CREATE TABLE field ( + field_id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT +); + +CREATE TABLE location ( + location_id SERIAL PRIMARY KEY, + field_id INT NOT NULL REFERENCES field(field_id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + address TEXT, + latitude FLOAT, + longitude FLOAT +); + +CREATE TABLE device ( + device_id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + modbus_unit_id INT NOT NULL, + status VARCHAR(50) DEFAULT 'active' +); + +CREATE TABLE wellHead ( + wellhead_id SERIAL PRIMARY KEY, + location_id INT NOT NULL REFERENCES location(location_id) ON DELETE CASCADE, + device_id INT UNIQUE NOT NULL REFERENCES device(device_id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL UNIQUE, + type VARCHAR(100), + status VARCHAR(50) DEFAULT 'active' +); + +-- Parameter metadata -------------------------------------------------------- + +CREATE TABLE parameterType ( + parameter_type_id SERIAL PRIMARY KEY, + code VARCHAR(100) NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + canonical_unit VARCHAR(50), + data_type VARCHAR(50) NOT NULL, + normal_min FLOAT, + normal_max FLOAT +); + +CREATE TABLE deviceParameterMapping ( + mapping_id SERIAL PRIMARY KEY, + device_id INT NOT NULL REFERENCES device(device_id) ON DELETE CASCADE, + parameter_type_id INT NOT NULL REFERENCES parameterType(parameter_type_id) ON DELETE CASCADE, + modbus_register INT NOT NULL, + function_code SMALLINT NOT NULL, + register_type VARCHAR(50) NOT NULL, + active BOOLEAN DEFAULT TRUE, + UNIQUE(device_id, modbus_register) +); + +-- Time-series --------------------------------------------------------------- + +CREATE TABLE parameterReading ( + parameter_reading_id BIGSERIAL, + timestamp_utc TIMESTAMPTZ NOT NULL, + wellhead_id INT NOT NULL REFERENCES wellHead(wellhead_id) ON DELETE CASCADE, + parameter_type_id INT NOT NULL REFERENCES parameterType(parameter_type_id) ON DELETE CASCADE, + mapping_id INT NOT NULL REFERENCES deviceParameterMapping(mapping_id) ON DELETE CASCADE, + raw_value FLOAT NOT NULL, + inserted_at TIMESTAMPTZ DEFAULT NOW(), + source_kind TEXT NOT NULL DEFAULT 'synthetic_random_simulator' + CONSTRAINT parameter_reading_source_kind_check CHECK (source_kind IN ( + 'synthetic_random_simulator', 'synthetic_reduced_order_model' + )), + model_version model_version_text, + PRIMARY KEY (parameter_reading_id, timestamp_utc) +); + +SELECT create_hypertable('parameterReading', 'timestamp_utc', if_not_exists => TRUE); + +-- Every per-wellhead query filters on exactly this pair; no index covered it. +CREATE INDEX ix_parameter_reading_wellhead_time + ON parameterReading (wellhead_id, timestamp_utc DESC); + +CREATE INDEX ix_parameter_reading_type_time + ON parameterReading (parameter_type_id, timestamp_utc DESC); + +-- Alarms -------------------------------------------------------------------- + +CREATE TABLE alarmRule ( + alarm_rule_id SERIAL PRIMARY KEY, + parameter_type_id INT NOT NULL REFERENCES parameterType(parameter_type_id) ON DELETE CASCADE, + severity_level VARCHAR(50) NOT NULL, + operator VARCHAR(10) NOT NULL, + threshold_value FLOAT NOT NULL, + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + trigger_after INT NOT NULL DEFAULT 3, + clear_after INT NOT NULL DEFAULT 3 +); + +CREATE UNIQUE INDEX uq_alarm_rule_definition + ON alarmRule (parameter_type_id, severity_level, operator, threshold_value); + +CREATE TABLE alarmEvent ( + event_id BIGSERIAL NOT NULL, + alarm_rule_id INT NOT NULL REFERENCES alarmRule(alarm_rule_id) ON DELETE CASCADE, + parameter_reading_id BIGINT NOT NULL, + timestamp_utc TIMESTAMPTZ NOT NULL, + wellhead_id INT NOT NULL REFERENCES wellHead(wellhead_id) ON DELETE CASCADE, + triggered_at TIMESTAMPTZ NOT NULL, + cleared_at TIMESTAMPTZ, + severity_level VARCHAR(50) NOT NULL, + triggered_value FLOAT NOT NULL, + PRIMARY KEY (event_id, triggered_at) +); + +SELECT create_hypertable('alarmEvent', 'triggered_at', if_not_exists => TRUE); + +-- The active-alarms view filters on exactly this predicate. +CREATE INDEX ix_alarm_event_open + ON alarmEvent (wellhead_id, alarm_rule_id) WHERE cleared_at IS NULL; + + +-- Alarm evaluation with consecutive breach and clear counters. + +COMMENT ON COLUMN alarmRule.trigger_after IS + 'Consecutive breaching evaluations required before the alarm opens.'; +COMMENT ON COLUMN alarmRule.clear_after IS + 'Consecutive non-breaching evaluations required before the alarm clears.'; + +-- Per wellhead and rule, how many consecutive evaluations have agreed. Keeping +-- the counters here makes evaluation O(1) per rule instead of a scan back +-- through reading history. +CREATE TABLE alarmRuleState ( + wellhead_id INT NOT NULL REFERENCES wellHead(wellhead_id) ON DELETE CASCADE, + alarm_rule_id INT NOT NULL REFERENCES alarmRule(alarm_rule_id) ON DELETE CASCADE, + consecutive_breaches INT NOT NULL DEFAULT 0, + consecutive_clears INT NOT NULL DEFAULT 0, + last_evaluated_at TIMESTAMPTZ, + PRIMARY KEY (wellhead_id, alarm_rule_id) +); + +CREATE OR REPLACE FUNCTION evaluate_wellhead_alarms() +RETURNS TRIGGER AS $$ +BEGIN + -- One evaluation per (wellhead, rule) per statement. The ingestion service + -- writes one reading per wellhead and parameter per batch, so the latest + -- row in the batch is that evaluation. A batch carrying several samples for + -- the same parameter counts as one evaluation, which delays a state change + -- rather than causing a wrong one. + WITH latest AS ( + SELECT DISTINCT ON (i.wellhead_id, i.parameter_type_id) + i.wellhead_id, + i.parameter_type_id, + i.parameter_reading_id, + i.timestamp_utc, + i.raw_value + FROM inserted i + ORDER BY i.wellhead_id, i.parameter_type_id, i.timestamp_utc DESC + ), + evaluated AS ( + SELECT l.wellhead_id, + r.alarm_rule_id, + r.severity_level, + r.trigger_after, + r.clear_after, + l.parameter_reading_id, + l.timestamp_utc, + l.raw_value, + CASE r.operator + WHEN '>' THEN l.raw_value > r.threshold_value + WHEN '>=' THEN l.raw_value >= r.threshold_value + WHEN '<' THEN l.raw_value < r.threshold_value + WHEN '<=' THEN l.raw_value <= r.threshold_value + WHEN '==' THEN l.raw_value = r.threshold_value + ELSE FALSE + END AS breaching + FROM latest l + JOIN alarmRule r + ON r.parameter_type_id = l.parameter_type_id + AND r.active = TRUE + ), + counted AS ( + INSERT INTO alarmRuleState AS s ( + wellhead_id, alarm_rule_id, + consecutive_breaches, consecutive_clears, last_evaluated_at + ) + SELECT e.wellhead_id, + e.alarm_rule_id, + CASE WHEN e.breaching THEN 1 ELSE 0 END, + CASE WHEN e.breaching THEN 0 ELSE 1 END, + e.timestamp_utc + FROM evaluated e + ON CONFLICT (wellhead_id, alarm_rule_id) DO UPDATE + SET consecutive_breaches = CASE + WHEN EXCLUDED.consecutive_breaches > 0 + THEN s.consecutive_breaches + 1 ELSE 0 END, + consecutive_clears = CASE + WHEN EXCLUDED.consecutive_clears > 0 + THEN s.consecutive_clears + 1 ELSE 0 END, + last_evaluated_at = EXCLUDED.last_evaluated_at + RETURNING s.wellhead_id, s.alarm_rule_id, + s.consecutive_breaches, s.consecutive_clears + ), + decided AS ( + SELECT e.*, c.consecutive_breaches, c.consecutive_clears + FROM evaluated e + JOIN counted c + ON c.wellhead_id = e.wellhead_id + AND c.alarm_rule_id = e.alarm_rule_id + ), + opened AS ( + INSERT INTO alarmEvent ( + alarm_rule_id, parameter_reading_id, wellhead_id, timestamp_utc, + triggered_at, severity_level, triggered_value + ) + SELECT d.alarm_rule_id, d.parameter_reading_id, d.wellhead_id, + d.timestamp_utc, d.timestamp_utc, d.severity_level, d.raw_value + FROM decided d + WHERE d.breaching + AND d.consecutive_breaches >= d.trigger_after + AND NOT EXISTS ( + SELECT 1 FROM alarmEvent a + WHERE a.wellhead_id = d.wellhead_id + AND a.alarm_rule_id = d.alarm_rule_id + AND a.cleared_at IS NULL + ) + RETURNING 1 + ) + UPDATE alarmEvent a + SET cleared_at = d.timestamp_utc + FROM decided d + WHERE a.wellhead_id = d.wellhead_id + AND a.alarm_rule_id = d.alarm_rule_id + AND a.cleared_at IS NULL + AND NOT d.breaching + AND d.consecutive_clears >= d.clear_after; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + + +CREATE TRIGGER evaluate_wellhead_alarms_trigger +AFTER INSERT ON parameterReading +REFERENCING NEW TABLE AS inserted +FOR EACH STATEMENT +EXECUTE FUNCTION evaluate_wellhead_alarms(); + + + +-- Dashboard read views. + +CREATE VIEW v_wellhead_parameter_readings AS +SELECT + pr.timestamp_utc, + pr.raw_value, + wh.wellhead_id, + wh.name AS wellhead_name, + wh.type AS wellhead_type, + loc.location_id, + loc.name AS location_name, + f.field_id, + f.name AS field_name, + pt.parameter_type_id, + pt.code AS parameter_code, + pt.display_name AS parameter_display_name, + pt.canonical_unit, + pt.data_type, + pt.normal_min, + pt.normal_max +FROM parameterReading pr +JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id +JOIN location loc ON wh.location_id = loc.location_id +JOIN field f ON loc.field_id = f.field_id +JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id; + +-- Open alarms with the threshold context needed to explain why they fired. +CREATE VIEW v_active_alarms AS +SELECT + ae.event_id, + ae.triggered_at, + ae.severity_level, + ae.triggered_value, + wh.wellhead_id, + wh.name AS wellhead_name, + loc.name AS location_name, + f.name AS field_name, + pt.code AS parameter_code, + pt.display_name AS parameter_display_name, + pt.canonical_unit, + ar.operator, + ar.threshold_value, + ar.alarm_rule_id +FROM alarmEvent ae +JOIN wellHead wh ON ae.wellhead_id = wh.wellhead_id +JOIN location loc ON wh.location_id = loc.location_id +JOIN field f ON loc.field_id = f.field_id +JOIN alarmRule ar ON ae.alarm_rule_id = ar.alarm_rule_id +JOIN parameterType pt ON ar.parameter_type_id = pt.parameter_type_id +WHERE ae.cleared_at IS NULL; + + +-- Trend summaries and their refresh policies. + +CREATE MATERIALIZED VIEW mv_2min_pressure_trends +WITH (timescaledb.continuous) AS +SELECT + time_bucket('2 minutes', pr.timestamp_utc) AS bucket_time, + pr.wellhead_id, + wh.name AS wellhead_name, + loc.name AS location_name, + f.name AS field_name, + pt.code AS parameter_code, + pt.display_name AS parameter_display_name, + pt.canonical_unit, + AVG(pr.raw_value) AS avg_value, + MIN(pr.raw_value) AS min_value, + MAX(pr.raw_value) AS max_value, + COUNT(pr.raw_value) AS reading_count +FROM parameterReading pr +JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id +JOIN location loc ON wh.location_id = loc.location_id +JOIN field f ON loc.field_id = f.field_id +JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id +WHERE pt.code IN ('tubing_pressure', 'casing_pressure', 'annulus_pressure') +GROUP BY 1, pr.wellhead_id, wh.name, loc.name, f.name, + pt.code, pt.display_name, pt.canonical_unit; + +SELECT add_continuous_aggregate_policy('mv_2min_pressure_trends', + start_offset => INTERVAL '1 day', + end_offset => INTERVAL '2 minutes', + schedule_interval => INTERVAL '2 minutes'); + +CREATE MATERIALIZED VIEW mv_2min_temp_flow_trends +WITH (timescaledb.continuous) AS +SELECT + time_bucket('2 minutes', pr.timestamp_utc) AS bucket_time, + pr.wellhead_id, + wh.name AS wellhead_name, + loc.name AS location_name, + f.name AS field_name, + pt.code AS parameter_code, + pt.display_name AS parameter_display_name, + pt.canonical_unit, + AVG(pr.raw_value) AS avg_value, + MIN(pr.raw_value) AS min_value, + MAX(pr.raw_value) AS max_value, + COUNT(pr.raw_value) AS reading_count +FROM parameterReading pr +JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id +JOIN location loc ON wh.location_id = loc.location_id +JOIN field f ON loc.field_id = f.field_id +JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id +WHERE pt.code IN ('wellhead_temperature', 'flow_rate') +GROUP BY 1, pr.wellhead_id, wh.name, loc.name, f.name, + pt.code, pt.display_name, pt.canonical_unit; + +SELECT add_continuous_aggregate_policy('mv_2min_temp_flow_trends', + start_offset => INTERVAL '1 day', + end_offset => INTERVAL '2 minutes', + schedule_interval => INTERVAL '2 minutes'); + +CREATE MATERIALIZED VIEW mv_2min_water_cut_gor_trends +WITH (timescaledb.continuous) AS +SELECT + time_bucket('2 minutes', pr.timestamp_utc) AS bucket_time, + pr.wellhead_id, + wh.name AS wellhead_name, + loc.name AS location_name, + f.name AS field_name, + pt.code AS parameter_code, + pt.display_name AS parameter_display_name, + pt.canonical_unit, + AVG(pr.raw_value) AS avg_value, + MIN(pr.raw_value) AS min_value, + MAX(pr.raw_value) AS max_value, + COUNT(pr.raw_value) AS reading_count +FROM parameterReading pr +JOIN wellHead wh ON pr.wellhead_id = wh.wellhead_id +JOIN location loc ON wh.location_id = loc.location_id +JOIN field f ON loc.field_id = f.field_id +JOIN parameterType pt ON pr.parameter_type_id = pt.parameter_type_id +WHERE pt.code IN ('water_cut', 'gas_oil_ratio') +GROUP BY 1, pr.wellhead_id, wh.name, loc.name, f.name, + pt.code, pt.display_name, pt.canonical_unit; + +SELECT add_continuous_aggregate_policy('mv_2min_water_cut_gor_trends', + start_offset => INTERVAL '1 day', + end_offset => INTERVAL '2 minutes', + schedule_interval => INTERVAL '2 minutes'); + +CREATE MATERIALIZED VIEW mv_5min_alarm_counts +WITH (timescaledb.continuous) AS +SELECT + time_bucket('5 minutes', ae.triggered_at) AS bucket_time, + ae.wellhead_id, + wh.name AS wellhead_name, + loc.name AS location_name, + pt.display_name AS parameter_display_name, + ae.severity_level, + COUNT(ae.event_id) AS alarms_triggered +FROM alarmEvent ae +JOIN wellHead wh ON ae.wellhead_id = wh.wellhead_id +JOIN location loc ON wh.location_id = loc.location_id +JOIN alarmRule ar ON ae.alarm_rule_id = ar.alarm_rule_id +JOIN parameterType pt ON ar.parameter_type_id = pt.parameter_type_id +GROUP BY 1, ae.wellhead_id, wh.name, loc.name, + pt.display_name, ae.severity_level; + +SELECT add_continuous_aggregate_policy('mv_5min_alarm_counts', + start_offset => INTERVAL '30 days', + end_offset => INTERVAL '5 minutes', + schedule_interval => INTERVAL '5 minutes'); + + +-- +-- Tiered retention and compression, implementing ADR 0026. +-- +-- Intervals are sized for a demonstrable portfolio system rather than a +-- production historian: at a five-second interval, 12 wellheads and 18 +-- parameters produce roughly 3.7 million rows per day, so raw readings are +-- compressed quickly and dropped after a week. The continuous aggregates keep +-- the longer-range trend. + +ALTER TABLE parameterReading SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'wellhead_id, parameter_type_id', + timescaledb.compress_orderby = 'timestamp_utc DESC' +); + +ALTER TABLE alarmEvent SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'wellhead_id, alarm_rule_id', + timescaledb.compress_orderby = 'triggered_at DESC' +); + +-- Compress raw readings once they are older than the trend windows that read +-- them uncompressed. +SELECT add_compression_policy('parameterReading', INTERVAL '1 day', + if_not_exists => TRUE); +SELECT add_compression_policy('alarmEvent', INTERVAL '7 days', + if_not_exists => TRUE); + +SELECT add_retention_policy('parameterReading', INTERVAL '7 days', + if_not_exists => TRUE); +SELECT add_retention_policy('alarmEvent', INTERVAL '90 days', + if_not_exists => TRUE); + +-- +-- The audit trail, implementing ADR 0034. +-- +-- Nothing in this system recorded who did anything. This table answers who did +-- what, when, and to which record. It is built for the person operating the +-- system rather than for a regulator, so there is no hash chain and no separate +-- store. ADR 0034 records why. + +CREATE TABLE auditEvent ( + audit_event_id BIGSERIAL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Null when no known user acted, which is every failed login against an + -- account that does not exist. + actor_user_id INT REFERENCES users(id) ON DELETE SET NULL, + + -- What the caller submitted. Recorded whether or not it matches an account, + -- so the presence of a row cannot be used to discover which accounts exist. + -- Never populated from the password field. + actor_identifier VARCHAR(256), + + action VARCHAR(64) NOT NULL, + subject_type VARCHAR(64), + subject_id VARCHAR(64), + outcome VARCHAR(16) NOT NULL, + + -- Ties the row to one request and to the log lines that request produced. + request_id VARCHAR(64), + + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + + PRIMARY KEY (audit_event_id, occurred_at), + CONSTRAINT audit_event_outcome CHECK (outcome IN ('success', 'failure')) +); + +SELECT create_hypertable('auditEvent', 'occurred_at', if_not_exists => TRUE); + +-- Reading the trail means asking what one actor did, or what happened to one +-- subject, almost always most recent first. +CREATE INDEX ix_audit_event_actor + ON auditEvent (actor_user_id, occurred_at DESC); +CREATE INDEX ix_audit_event_action + ON auditEvent (action, occurred_at DESC); +CREATE INDEX ix_audit_event_subject + ON auditEvent (subject_type, subject_id, occurred_at DESC); + +-- Failed logins arrive from unauthenticated traffic, so the identifier is the +-- column an investigation starts from. +CREATE INDEX ix_audit_event_identifier + ON auditEvent (actor_identifier, occurred_at DESC) + WHERE actor_identifier IS NOT NULL; + +SELECT add_retention_policy('auditEvent', INTERVAL '90 days', + if_not_exists => TRUE); + +-- One indexed lookup per active mapping keeps the twin-core's latest-reading +-- endpoint bounded as historian data grows. +CREATE INDEX ix_parameter_reading_mapping_time + ON parameterReading (mapping_id, timestamp_utc DESC, parameter_reading_id DESC); + +-- Milestone 7: append-only model snapshots, recovery events, and audited +-- per-well parameter overrides. Policy functions run as separate statements. + +CREATE TABLE twin_state_snapshot ( + snapshot_id BIGSERIAL NOT NULL, + simulated_at TIMESTAMPTZ NOT NULL, + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id), + model_version model_version_text NOT NULL, + parameter_fingerprint TEXT NOT NULL, + status TEXT NOT NULL CHECK (status = 'running'), + tick_index BIGINT NOT NULL CHECK (tick_index >= 0), + skipped_ticks BIGINT NOT NULL CHECK (skipped_ticks >= 0), + tubing_pressure_psi DOUBLE PRECISION NOT NULL, + casing_pressure_psi DOUBLE PRECISION NOT NULL, + flow_rate_bpd DOUBLE PRECISION NOT NULL, + wellhead_temperature_f DOUBLE PRECISION NOT NULL, + water_cut_percent DOUBLE PRECISION NOT NULL, + choke_position_percent DOUBLE PRECISION NOT NULL, + blockage_factor DOUBLE PRECISION NOT NULL, + corrosion_factor DOUBLE PRECISION NOT NULL, + state JSONB NOT NULL, + inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (snapshot_id, simulated_at) +); +SELECT create_hypertable('twin_state_snapshot', 'simulated_at', if_not_exists => TRUE); +CREATE INDEX ix_twin_state_snapshot_well_time + ON twin_state_snapshot (wellhead_id, simulated_at DESC); +ALTER TABLE twin_state_snapshot SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'wellhead_id', + timescaledb.compress_orderby = 'simulated_at DESC' +); +SELECT add_compression_policy('twin_state_snapshot', INTERVAL '7 days', if_not_exists => TRUE); +SELECT add_retention_policy('twin_state_snapshot', INTERVAL '90 days', if_not_exists => TRUE); + +CREATE TABLE twin_recovery_event ( + event_id BIGSERIAL NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + wellhead_id INT REFERENCES wellhead(wellhead_id), + event_type TEXT NOT NULL CHECK (event_type IN ( + 'snapshot_restored', 'cold_start_no_snapshot', 'snapshot_incompatible', + 'clock_jump_forward', 'clock_jump_backward', 'active_controls_restored' + )), + model_version model_version_text NOT NULL, + snapshot_simulated_at TIMESTAMPTZ, + gap_seconds DOUBLE PRECISION CHECK (gap_seconds IS NULL OR gap_seconds >= 0), + details JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (event_id, occurred_at) +); +SELECT create_hypertable('twin_recovery_event', 'occurred_at', if_not_exists => TRUE); +CREATE INDEX ix_twin_recovery_event_well_time + ON twin_recovery_event (wellhead_id, occurred_at DESC); +SELECT add_retention_policy('twin_recovery_event', INTERVAL '2 years', if_not_exists => TRUE); + +CREATE TABLE wellhead_model_parameter ( + parameter_override_id SERIAL PRIMARY KEY, + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id), + model_version model_version_text NOT NULL, + parameter_key TEXT NOT NULL, + value DOUBLE PRECISION, + unit TEXT, + source TEXT NOT NULL CHECK (length(trim(source)) > 0), + changed_by TEXT NOT NULL CHECK (length(trim(changed_by)) > 0), + reason TEXT NOT NULL CHECK (length(trim(reason)) > 0), + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ((value IS NULL) = (unit IS NULL)), + UNIQUE (wellhead_id, model_version, parameter_key, valid_from) +); +CREATE INDEX ix_wellhead_model_parameter_current + ON wellhead_model_parameter (wellhead_id, model_version, parameter_key, valid_from DESC); + +-- Milestone 8: complete, attributed model projections. Runs are ordinary +-- tables; sampled future points are hypertables with time in each primary key. + +CREATE TABLE prediction_run ( + run_id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id), + requested_by_user_id INT NOT NULL REFERENCES users(id), + model_version model_version_text NOT NULL, + parameter_fingerprint TEXT NOT NULL, + base_simulated_at TIMESTAMPTZ NOT NULL, + horizon_seconds INT NOT NULL CHECK (horizon_seconds BETWEEN 300 AND 7200), + step_seconds INT NOT NULL CHECK (step_seconds BETWEEN 5 AND 300), + base_state JSONB NOT NULL, + parameter_values JSONB NOT NULL, + control_values JSONB NOT NULL, + alarm_rules JSONB NOT NULL, + confidence_score DOUBLE PRECISION NOT NULL CHECK (confidence_score BETWEEN 0 AND 1), + confidence_reasons JSONB NOT NULL +); +CREATE INDEX ix_prediction_run_well_created + ON prediction_run (wellhead_id, created_at DESC); + +CREATE TABLE prediction_point ( + run_id UUID NOT NULL REFERENCES prediction_run(run_id), + forecast_at TIMESTAMPTZ NOT NULL, + elapsed_seconds INT NOT NULL CHECK (elapsed_seconds > 0), + values_json JSONB NOT NULL, + tubing_pressure_psi DOUBLE PRECISION NOT NULL CHECK (tubing_pressure_psi >= 0), + flow_rate_bpd DOUBLE PRECISION NOT NULL CHECK (flow_rate_bpd >= 0), + wellhead_temperature_f DOUBLE PRECISION NOT NULL, + water_cut_percent DOUBLE PRECISION NOT NULL CHECK (water_cut_percent BETWEEN 0 AND 100), + alarm_risk TEXT NOT NULL CHECK (alarm_risk IN ('none', 'warning', 'critical', 'unknown')), + triggered_rule_ids JSONB NOT NULL, + PRIMARY KEY (run_id, forecast_at) +); +SELECT create_hypertable('prediction_point', 'forecast_at', if_not_exists => TRUE); +ALTER TABLE prediction_point SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'run_id', + timescaledb.compress_orderby = 'forecast_at DESC' +); +SELECT add_compression_policy('prediction_point', INTERVAL '3 days', if_not_exists => TRUE); +SELECT add_retention_policy('prediction_point', INTERVAL '30 days', if_not_exists => TRUE); + +CREATE TABLE scenario_run ( + run_id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id), + requested_by_user_id INT NOT NULL REFERENCES users(id), + name VARCHAR(120) NOT NULL CHECK (length(trim(name)) > 0), + model_version model_version_text NOT NULL, + parameter_fingerprint TEXT NOT NULL, + base_simulated_at TIMESTAMPTZ NOT NULL, + horizon_seconds INT NOT NULL CHECK (horizon_seconds BETWEEN 300 AND 7200), + step_seconds INT NOT NULL CHECK (step_seconds BETWEEN 5 AND 300), + base_state JSONB NOT NULL, + parameter_values JSONB NOT NULL, + control_values JSONB NOT NULL, + scheduled_controls JSONB NOT NULL, + alarm_rules JSONB NOT NULL, + confidence_score DOUBLE PRECISION NOT NULL CHECK (confidence_score BETWEEN 0 AND 1), + confidence_reasons JSONB NOT NULL +); +CREATE INDEX ix_scenario_run_well_created + ON scenario_run (wellhead_id, created_at DESC); + +CREATE TABLE scenario_point ( + run_id UUID NOT NULL REFERENCES scenario_run(run_id), + forecast_at TIMESTAMPTZ NOT NULL, + elapsed_seconds INT NOT NULL CHECK (elapsed_seconds > 0), + values_json JSONB NOT NULL, + tubing_pressure_psi DOUBLE PRECISION NOT NULL CHECK (tubing_pressure_psi >= 0), + flow_rate_bpd DOUBLE PRECISION NOT NULL CHECK (flow_rate_bpd >= 0), + wellhead_temperature_f DOUBLE PRECISION NOT NULL, + water_cut_percent DOUBLE PRECISION NOT NULL CHECK (water_cut_percent BETWEEN 0 AND 100), + baseline_values_json JSONB NOT NULL, + alarm_risk TEXT NOT NULL CHECK (alarm_risk IN ('none', 'warning', 'critical', 'unknown')), + baseline_alarm_risk TEXT NOT NULL CHECK (baseline_alarm_risk IN ('none', 'warning', 'critical', 'unknown')), + triggered_rule_ids JSONB NOT NULL, + PRIMARY KEY (run_id, forecast_at) +); +SELECT create_hypertable('scenario_point', 'forecast_at', if_not_exists => TRUE); +ALTER TABLE scenario_point SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'run_id', + timescaledb.compress_orderby = 'forecast_at DESC' +); +SELECT add_compression_policy('scenario_point', INTERVAL '7 days', if_not_exists => TRUE); +SELECT add_retention_policy('scenario_point', INTERVAL '90 days', if_not_exists => TRUE); + +-- Durable synthetic control commands. No field-device write path is added. +CREATE TABLE control_command ( + command_id UUID PRIMARY KEY, + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id), + created_by INT NOT NULL REFERENCES users(id), + idempotency_key UUID NOT NULL, + reason VARCHAR(500) NOT NULL CHECK (length(trim(reason)) > 0), + expected_revision INT NOT NULL CHECK (expected_revision >= 0), + model_version model_version_text NOT NULL, + parameter_fingerprint TEXT NOT NULL, + choke_position_percent DOUBLE PRECISION CHECK (choke_position_percent BETWEEN 0 AND 100), + master_valve_open BOOLEAN, + wing_valve_open BOOLEAN, + pump_on BOOLEAN, + emergency_shutdown BOOLEAN, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN + ('draft', 'queued', 'applied', 'rejected', 'expired', 'cancelled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (clock_timestamp() + INTERVAL '24 hours'), + queued_at TIMESTAMPTZ, + activated_by INT REFERENCES users(id), + activated_role TEXT, + activated_session_version INT, + result_at TIMESTAMPTZ, + result_revision INT, + result_reason VARCHAR(64), + request_id VARCHAR(64), + UNIQUE (created_by, idempotency_key), + CHECK (num_nonnulls(choke_position_percent, master_valve_open, + wing_valve_open, pump_on, emergency_shutdown) > 0), + CHECK (emergency_shutdown IS NULL OR num_nonnulls(choke_position_percent, + master_valve_open, wing_valve_open, pump_on) = 0), + CHECK (status <> 'queued' OR (queued_at IS NOT NULL AND activated_by IS NOT NULL AND activated_by = created_by + AND activated_role IS NOT NULL AND activated_session_version IS NOT NULL)), + CHECK ((status IN ('applied', 'rejected', 'expired', 'cancelled')) = (result_at IS NOT NULL)), + CHECK (status <> 'applied' OR (result_revision IS NOT NULL AND result_revision = expected_revision + 1)) +); +CREATE UNIQUE INDEX ix_control_one_draft + ON control_command(created_by, wellhead_id) WHERE status = 'draft'; +CREATE UNIQUE INDEX ix_control_one_queued + ON control_command(wellhead_id) WHERE status = 'queued'; +CREATE INDEX ix_control_inbox ON control_command(queued_at, command_id) + WHERE status = 'queued'; + +CREATE TABLE wellhead_control_state ( + wellhead_id INT PRIMARY KEY REFERENCES wellhead(wellhead_id), + revision INT NOT NULL DEFAULT 0 CHECK (revision >= 0), + model_version model_version_text NOT NULL, + parameter_fingerprint TEXT NOT NULL, + choke_position_percent DOUBLE PRECISION NOT NULL CHECK (choke_position_percent BETWEEN 0 AND 100), + master_valve_open BOOLEAN NOT NULL, + wing_valve_open BOOLEAN NOT NULL, + swab_valve_open BOOLEAN NOT NULL, + pump_on BOOLEAN NOT NULL, + emergency_shutdown BOOLEAN NOT NULL, + applied_command_id UUID REFERENCES control_command(command_id), + applied_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE control_command_event ( + event_id BIGSERIAL PRIMARY KEY, + command_id UUID NOT NULL REFERENCES control_command(command_id), + event_type VARCHAR(32) NOT NULL CHECK (event_type IN + ('draft', 'queued', 'applied', 'rejected', 'expired', 'cancelled')), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + actor_user_id INT REFERENCES users(id), + reason_code VARCHAR(64) NOT NULL, + request_id VARCHAR(64) +); +CREATE INDEX ix_control_event_command ON control_command_event(command_id, event_id); + +CREATE TABLE model_parameter_change ( + change_id UUID PRIMARY KEY, + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id), + created_by INT NOT NULL REFERENCES users(id), + idempotency_key UUID NOT NULL, + model_version model_version_text NOT NULL, + parameter_key TEXT NOT NULL, + value DOUBLE PRECISION, + unit TEXT, + reason VARCHAR(500) NOT NULL CHECK (length(trim(reason)) > 0), + expected_override_id INT REFERENCES wellhead_model_parameter(parameter_override_id), + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'cancelled', 'expired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT (clock_timestamp() + INTERVAL '24 hours'), + result_at TIMESTAMPTZ, + published_override_id INT REFERENCES wellhead_model_parameter(parameter_override_id), + UNIQUE (created_by, idempotency_key), + CHECK ((value IS NULL) = (unit IS NULL)), + CHECK (value IS NULL OR (value > '-Infinity'::float8 AND value < 'Infinity'::float8)), + CHECK ((status <> 'draft') = (result_at IS NOT NULL)), + CHECK ((status = 'published') = (published_override_id IS NOT NULL)) +); +CREATE UNIQUE INDEX ix_parameter_one_draft + ON model_parameter_change(created_by, wellhead_id, parameter_key) WHERE status = 'draft'; + +-- Narrow lock helpers avoid granting twin-core user-update rights or granting +-- the API active-control-update rights merely to acquire a row lock. +CREATE FUNCTION control_actor_authority(actor_id INT) +RETURNS TABLE(role TEXT, status TEXT, deleted BOOLEAN, session_version INT) +LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp +AS $$ SELECT role::text, status::text, deleted, session_version + FROM public.users WHERE id = actor_id FOR SHARE $$; +REVOKE ALL ON FUNCTION control_actor_authority(INT) FROM PUBLIC; + +CREATE FUNCTION lock_control_state(well_id INT) RETURNS VOID +LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp +AS $$ SELECT 1 FROM public.wellhead_control_state WHERE wellhead_id = well_id FOR UPDATE $$; +REVOKE ALL ON FUNCTION lock_control_state(INT) FROM PUBLIC; + +CREATE FUNCTION enforce_control_lifecycle() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ +BEGIN + IF (to_jsonb(NEW) - ARRAY['status','expires_at','queued_at','activated_by', + 'activated_role','activated_session_version','result_at','result_revision','result_reason']) + IS DISTINCT FROM + (to_jsonb(OLD) - ARRAY['status','expires_at','queued_at','activated_by', + 'activated_role','activated_session_version','result_at','result_revision','result_reason']) THEN + RAISE EXCEPTION 'Control proposals are immutable'; + END IF; + IF NOT ((OLD.status = 'draft' AND NEW.status IN ('queued','cancelled','expired')) + OR (OLD.status = 'queued' AND NEW.status IN ('applied','rejected','expired','cancelled'))) THEN + RAISE EXCEPTION 'Invalid control lifecycle transition'; + END IF; + IF OLD.status = 'queued' AND + ROW(NEW.queued_at, NEW.expires_at, NEW.activated_by, NEW.activated_role, NEW.activated_session_version) + IS DISTINCT FROM + ROW(OLD.queued_at, OLD.expires_at, OLD.activated_by, OLD.activated_role, OLD.activated_session_version) THEN + RAISE EXCEPTION 'Activation authority is immutable'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER enforce_control_lifecycle BEFORE UPDATE ON control_command + FOR EACH ROW EXECUTE FUNCTION enforce_control_lifecycle(); + +CREATE FUNCTION enforce_parameter_change_lifecycle() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$ +BEGIN + IF (to_jsonb(NEW) - ARRAY['status','result_at','published_override_id']) + IS DISTINCT FROM (to_jsonb(OLD) - ARRAY['status','result_at','published_override_id']) + OR OLD.status <> 'draft' OR NEW.status NOT IN ('published','cancelled','expired') THEN + RAISE EXCEPTION 'Invalid parameter change lifecycle transition'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER enforce_parameter_change_lifecycle BEFORE UPDATE ON model_parameter_change + FOR EACH ROW EXECUTE FUNCTION enforce_parameter_change_lifecycle(); + +-- Scheduled forecasts: configuration and immutable claimed-work context. +CREATE TABLE forecast_schedule ( + schedule_id UUID PRIMARY KEY, + wellhead_id INT NOT NULL UNIQUE REFERENCES wellhead(wellhead_id), + owner_user_id INT NOT NULL REFERENCES users(id), + owner_role TEXT NOT NULL CHECK (owner_role = 'ADMIN'), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + interval_seconds INT NOT NULL CHECK (interval_seconds BETWEEN 300 AND 86400 AND interval_seconds % 60 = 0), + horizon_seconds INT NOT NULL CHECK (horizon_seconds BETWEEN 300 AND 1800 AND horizon_seconds % 60 = 0), + step_seconds INT NOT NULL DEFAULT 60 CHECK (step_seconds = 60), + revision INT NOT NULL CHECK (revision > 0), + next_due_at TIMESTAMPTZ, + reason VARCHAR(500) NOT NULL CHECK (length(trim(reason)) > 0), + updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + CHECK (enabled = (next_due_at IS NOT NULL)) +); +CREATE INDEX ix_forecast_schedule_due ON forecast_schedule(next_due_at, schedule_id) WHERE enabled; + +CREATE TABLE forecast_job ( + job_id UUID PRIMARY KEY, + schedule_id UUID NOT NULL REFERENCES forecast_schedule(schedule_id), + schedule_revision INT NOT NULL CHECK (schedule_revision > 0), + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id), + owner_user_id INT NOT NULL REFERENCES users(id), + owner_role TEXT NOT NULL CHECK (owner_role = 'ADMIN'), + interval_seconds INT NOT NULL CHECK (interval_seconds BETWEEN 300 AND 86400 AND interval_seconds % 60 = 0), + horizon_seconds INT NOT NULL CHECK (horizon_seconds BETWEEN 300 AND 1800 AND horizon_seconds % 60 = 0), + step_seconds INT NOT NULL CHECK (step_seconds = 60), + scheduled_for TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + lease_expires_at TIMESTAMPTZ NOT NULL, + missed_intervals INT NOT NULL CHECK (missed_intervals >= 0), + status TEXT NOT NULL CHECK (status IN ('running', 'complete', 'skipped', 'failed')), + completed_at TIMESTAMPTZ, + reason_code VARCHAR(64), + run_id UUID REFERENCES prediction_run(run_id), + UNIQUE (schedule_id, scheduled_for), + CHECK ((status = 'running') = (completed_at IS NULL)), + CHECK ((status = 'complete') = (run_id IS NOT NULL)), + CHECK (lease_expires_at > started_at) +); +CREATE UNIQUE INDEX ix_forecast_job_running ON forecast_job(schedule_id) WHERE status = 'running'; +CREATE INDEX ix_forecast_job_recent ON forecast_job(wellhead_id, started_at DESC); +CREATE INDEX ix_forecast_job_expiry ON forecast_job(lease_expires_at) WHERE status = 'running'; + +-- Operator actions are independent of the retained alarm condition events. +CREATE TABLE alarm_action ( + action_id UUID PRIMARY KEY, + actor_user_id INT NOT NULL, + actor_name VARCHAR(256) NOT NULL, + actor_role TEXT NOT NULL CHECK (actor_role = 'OPERATIONS'), + request_key UUID NOT NULL, + input_hash VARCHAR(64) NOT NULL CHECK (input_hash ~ '^[0-9a-f]{64}$'), + action TEXT NOT NULL CHECK (action IN ('acknowledge', 'shelve', 'unshelve')), + wellhead_id INT NOT NULL, + alarm_rule_id INT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL CHECK (isfinite(occurred_at)), + input JSONB NOT NULL CHECK (jsonb_typeof(input) = 'object'), + result JSONB NOT NULL CHECK (jsonb_typeof(result) = 'object'), + UNIQUE (actor_user_id, request_key) +); +CREATE INDEX ix_alarm_action_target ON alarm_action(wellhead_id, alarm_rule_id, occurred_at DESC, action_id DESC); + +CREATE TABLE alarm_acknowledgement ( + event_id BIGINT NOT NULL CHECK (event_id > 0), + triggered_at TIMESTAMPTZ NOT NULL CHECK (isfinite(triggered_at)), + acknowledged_at TIMESTAMPTZ NOT NULL CHECK (isfinite(acknowledged_at)), + action_id UUID NOT NULL UNIQUE REFERENCES alarm_action(action_id), + PRIMARY KEY (event_id, triggered_at) +); + +CREATE TABLE alarm_shelf ( + wellhead_id INT NOT NULL REFERENCES wellhead(wellhead_id) ON DELETE CASCADE, + alarm_rule_id INT NOT NULL REFERENCES alarmrule(alarm_rule_id) ON DELETE CASCADE, + revision INT NOT NULL CHECK (revision > 0), + shelved_until TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL CHECK (isfinite(updated_at)), + action_id UUID NOT NULL REFERENCES alarm_action(action_id), + PRIMARY KEY (wellhead_id, alarm_rule_id), + CHECK (shelved_until IS NULL OR (isfinite(shelved_until) AND + shelved_until >= updated_at + INTERVAL '5 minutes' AND shelved_until <= updated_at + INTERVAL '8 hours')) +); +CREATE INDEX ix_alarm_shelf_deadline ON alarm_shelf(shelved_until) WHERE shelved_until IS NOT NULL; diff --git a/data/src/database_ingestion.py b/data/src/database_ingestion.py index 2c4b399..f2cc63a 100644 --- a/data/src/database_ingestion.py +++ b/data/src/database_ingestion.py @@ -15,9 +15,11 @@ from model_source import supported_mappings from telemetry_common import ( HEARTBEAT_REGISTER, + STATUS_BLOCK_REGISTERS, WORD_ORDER, DatabaseSettings, MetadataReloader, + ModelVersion, ParameterMapping, ServiceHealth, as_number, @@ -34,9 +36,11 @@ ) from telemetry_metrics import ( INGESTION_RECONNECTS, + LAST_COMMIT_TIMESTAMP, METADATA_RELOADS, MODBUS_READ_ERRORS, POLL_DURATION, + POLL_RETRIES, POLLS_SKIPPED_STALE, READINGS_WRITTEN, TELEMETRY_AGE, @@ -47,14 +51,19 @@ RECONNECT_DELAY_SECONDS = 5 MAX_RECONNECT_DELAY_SECONDS = 120 REGISTERS_PER_VALUE = 2 +# Pause before re-reading the status block after a poll was overtaken. A +# gateway update takes milliseconds, so this usually skips past the moment +# when the block reads zero. +RETRY_WAIT_SECONDS = 0.1 DEFAULT_SERVICE_PORT = 9101 DATATYPE = ModbusClientMixin.DATATYPE INSERT_READING_SQL = """ INSERT INTO parameterReading ( - timestamp_utc, wellhead_id, parameter_type_id, mapping_id, raw_value, source_kind -) VALUES (%s, %s, %s, %s, %s, %s) + timestamp_utc, wellhead_id, parameter_type_id, mapping_id, raw_value, source_kind, + model_version +) VALUES (%s, %s, %s, %s, %s, %s, %s) """ logger = configure_logging("database_ingestion") @@ -90,8 +99,9 @@ class Reading: parameter_type_id: int mapping_id: int raw_value: float + model_version: ModelVersion - def as_row(self) -> tuple[datetime, int, int, int, float, str]: + def as_row(self) -> tuple[datetime, int, int, int, float, str, str]: """Return the tuple expected by INSERT_READING_SQL.""" return ( self.timestamp_utc, @@ -100,9 +110,18 @@ def as_row(self) -> tuple[datetime, int, int, int, float, str]: self.mapping_id, self.raw_value, "synthetic_reduced_order_model", + str(self.model_version), ) +@dataclass(frozen=True) +class GatewayStatus: + """The tick time and model version the gateway published together.""" + + heartbeat: int + model_version: ModelVersion + + def decode_registers(registers: list[int], data_type: str) -> float | None: """Decode two Modbus registers according to the configured parameter type.""" if data_type == "float": @@ -128,10 +147,15 @@ def is_stale(heartbeat: int | None, last_heartbeat: int | None) -> bool: return heartbeat is None or heartbeat == last_heartbeat -def read_heartbeat(client: ModbusTcpClient, unit_id: int) -> int | None: - """Read the model tick epoch seconds, or None while the image is invalid.""" +def read_status(client: ModbusTcpClient, unit_id: int) -> GatewayStatus | None: + """Read the tick time and model version, or None while either is unpublished. + + One Read Holding Registers request covers the whole status block (offset + 1900, quantity 5), so the heartbeat and the version come from one moment on + the server. + """ result = client.read_holding_registers( - HEARTBEAT_REGISTER, count=REGISTERS_PER_VALUE, device_id=unit_id + HEARTBEAT_REGISTER, count=STATUS_BLOCK_REGISTERS, device_id=unit_id ) if result.isError(): MODBUS_READ_ERRORS.labels(kind="heartbeat").inc() @@ -141,7 +165,9 @@ def read_heartbeat(client: ModbusTcpClient, unit_id: int) -> int | None: heartbeat = int( as_number( ModbusClientMixin.convert_from_registers( - result.registers, DATATYPE.UINT32, word_order=WORD_ORDER + result.registers[:REGISTERS_PER_VALUE], + DATATYPE.UINT32, + word_order=WORD_ORDER, ), "heartbeat", ) @@ -158,22 +184,38 @@ def read_heartbeat(client: ModbusTcpClient, unit_id: int) -> int | None: ) return None - return heartbeat + # A heartbeat without a version breaks the gateway's contract, which writes + # both in one call. Treat it like a zero heartbeat: nothing is stored. + try: + model_version = ModelVersion.from_registers( + list(result.registers[REGISTERS_PER_VALUE:]) + ) + except ValueError: + model_version = None + if model_version is None: + MODBUS_READ_ERRORS.labels(kind="heartbeat").inc() + logger.error( + "Gateway published a heartbeat without a valid model version", + extra={"unit_id": unit_id}, + ) + return None + return GatewayStatus(heartbeat, model_version) -def read_fleet_heartbeat( + +def read_fleet_status( client: ModbusTcpClient, mappings: list[ParameterMapping] -) -> int | None: - """Accept a model tick only when every mapped Modbus unit agrees.""" - common: int | None = None +) -> GatewayStatus | None: + """Accept a tick only when every mapped unit reports the same tick and version.""" + common: GatewayStatus | None = None for unit_id in collect_unit_ids(mappings): - heartbeat = read_heartbeat(client, unit_id) - if heartbeat is None: + status = read_status(client, unit_id) + if status is None: return None - if common is not None and heartbeat != common: - logger.warning("Modbus units report different model ticks") + if common is not None and status != common: + logger.warning("Modbus units report different model ticks or versions") return None - common = heartbeat + common = status return common @@ -181,6 +223,7 @@ def poll_once( client: ModbusTcpClient, mappings: list[ParameterMapping], timestamp: datetime, + model_version: ModelVersion, ) -> list[Reading]: """Read every mapped register once and return the decoded readings.""" readings: list[Reading] = [] @@ -221,12 +264,69 @@ def poll_once( parameter_type_id=mapping.parameter_type_id, mapping_id=mapping.mapping_id, raw_value=value, + model_version=model_version, ) ) return readings +def poll_consistent_batch( + client: ModbusTcpClient, + mappings: list[ParameterMapping], + status: GatewayStatus, + deadline: float, + interval_seconds: int, +) -> tuple[GatewayStatus, list[Reading]] | None: + """Poll one tick whose status block is the same before and after the reads. + + A gateway update that lands during a poll changes the status block, and the + readings then mix two ticks, so they cannot be stored. The poll used to be + dropped and the next try made a whole interval later. When the gateway and + ingestion loops ran in step, every poll collided and most batches were lost. + Instead, re-read the status block and poll the tick that just landed. + + `deadline` is a time.monotonic() value. Returns None when no consistent poll + completes before it, and the caller skips this interval as before. + """ + current = status + while True: + if time.monotonic() >= deadline: + return None + source_time = datetime.fromtimestamp(current.heartbeat, timezone.utc) + readings = poll_once(client, mappings, source_time, current.model_version) + if time.monotonic() >= deadline: + return None + ending_status = read_fleet_status(client, mappings) + if time.monotonic() >= deadline: + return None + if ending_status == current: + return current, readings + + POLL_RETRIES.inc() + logger.warning( + "Gateway updated during the poll; retrying", + extra={"heartbeat": current.heartbeat}, + ) + remaining = deadline - time.monotonic() + time.sleep(min(RETRY_WAIT_SECONDS, max(0.0, remaining))) + if time.monotonic() >= deadline: + return None + latest = read_fleet_status(client, mappings) + if time.monotonic() >= deadline: + return None + if latest is None or latest.heartbeat <= current.heartbeat: + return None + if ( + current_heartbeat( + latest.heartbeat, datetime.now(timezone.utc), interval_seconds + ) + is None + ): + return None + current = latest + + def apply_metadata_reload( reloader: MetadataReloader, current: list[ParameterMapping] ) -> list[ParameterMapping]: @@ -291,7 +391,8 @@ def ingest_forever( mappings = apply_metadata_reload(reloader, mappings) - heartbeat = read_fleet_heartbeat(client, mappings) + status = read_fleet_status(client, mappings) + heartbeat = status.heartbeat if status is not None else None if heartbeat is not None: TELEMETRY_AGE.set(max(0.0, now.timestamp() - heartbeat)) @@ -311,24 +412,37 @@ def ingest_forever( }, ) else: - if heartbeat is None: + if heartbeat is None or status is None: raise ValueError("Missing heartbeat before Modbus poll") - source_time = datetime.fromtimestamp(heartbeat, timezone.utc) + # Retries stop at half the interval, so the loop keeps its pace. + deadline = time.monotonic() + settings.poll_interval_seconds / 2 with POLL_DURATION.time(): - readings = poll_once(client, mappings, source_time) - ending_heartbeat = read_fleet_heartbeat(client, mappings) - if ending_heartbeat != heartbeat: + result = poll_consistent_batch( + client, + mappings, + status, + deadline, + settings.poll_interval_seconds, + ) + # The version is part of the status, so a twin-core redeploy + # between the two reads cannot label a batch with the wrong model. + if result is None: health.ready = False POLLS_SKIPPED_STALE.inc() - logger.warning("Discarding poll changed during Modbus reads") - elif len(readings) != len(mappings): - raise ValueError("Incomplete Modbus model batch") + logger.warning( + "Discarding poll: no consistent read before the deadline" + ) else: + # After a retry this is a newer tick than the first read. + polled_status, readings = result + if len(readings) != len(mappings): + raise ValueError("Incomplete Modbus model batch") execute_batch( cursor, INSERT_READING_SQL, [r.as_row() for r in readings] ) conn.commit() - last_heartbeat = heartbeat + LAST_COMMIT_TIMESTAMP.set(time.time()) + last_heartbeat = polled_status.heartbeat READINGS_WRITTEN.inc(len(readings)) # Only now is the service demonstrably doing its job. health.ready = True diff --git a/data/src/modbus_gateway.py b/data/src/modbus_gateway.py index 8888a68..2f7de18 100644 --- a/data/src/modbus_gateway.py +++ b/data/src/modbus_gateway.py @@ -5,7 +5,7 @@ import threading import time from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime import psycopg2 from pymodbus.client.mixin import ModbusClientMixin @@ -19,9 +19,11 @@ from model_source import fetch_model_snapshot, supported_mappings from telemetry_common import ( HEARTBEAT_REGISTER, + STATUS_BLOCK_REGISTERS, WORD_ORDER, DatabaseSettings, MetadataReloader, + ModelVersion, ParameterMapping, ServiceHealth, collect_unit_ids, @@ -166,9 +168,12 @@ def _index_mappings(self, mappings: list[ParameterMapping]) -> None: ] = mapping def apply_batch( - self, telemetry: list[dict[str, object]], at: datetime | None = None + self, + telemetry: list[dict[str, object]], + at: datetime, + model_version: ModelVersion, ) -> None: - """Write one telemetry batch into the register image.""" + """Write one telemetry batch, then publish its tick and model version.""" self.clear_heartbeat() for data_point in telemetry: wellhead_id = data_point["wellhead_id"] @@ -195,17 +200,28 @@ def apply_batch( encode_value(value, mapping.data_type), ) - self.write_heartbeat(at or datetime.now(timezone.utc)) + self.write_heartbeat(at, model_version) def clear_heartbeat(self) -> None: - """Mark a register update in progress before changing any value.""" + """Mark a register update in progress before changing any value. + + Zeroes the whole status block, so the model version is unpublished too + until the new batch is complete. + """ for unit_id in self.unit_ids: - self._write(unit_id, HEARTBEAT_REGISTER, [0, 0]) + self._write(unit_id, HEARTBEAT_REGISTER, [0] * STATUS_BLOCK_REGISTERS) + + def write_heartbeat(self, timestamp: datetime, model_version: ModelVersion) -> None: + """Publish the batch's tick time and model version to every unit. - def write_heartbeat(self, timestamp: datetime) -> None: - """Publish the time of the latest batch to every device context.""" - payload = ModbusClientMixin.convert_to_registers( - int(timestamp.timestamp()), DATATYPE.UINT32, word_order=WORD_ORDER + Both go in one five-register write, so a reader can never pair a new + heartbeat with the previous model version. + """ + payload = ( + ModbusClientMixin.convert_to_registers( + int(timestamp.timestamp()), DATATYPE.UINT32, word_order=WORD_ORDER + ) + + model_version.to_registers() ) for unit_id in self.unit_ids: self._write(unit_id, HEARTBEAT_REGISTER, payload) @@ -259,7 +275,7 @@ def run_model_source( batch = fetch_model_snapshot(settings.twin_core_url, expected_ids, max_age) if last_tick is not None and batch.simulated_at <= last_tick: raise ValueError("Model tick has not advanced") - store.apply_batch(batch.telemetry, batch.simulated_at) + store.apply_batch(batch.telemetry, batch.simulated_at, batch.model_version) except Exception: BATCH_ERRORS.inc() health.ready = False diff --git a/data/src/model_source.py b/data/src/model_source.py index 700dad0..3fce4f2 100644 --- a/data/src/model_source.py +++ b/data/src/model_source.py @@ -10,7 +10,7 @@ from urllib.parse import urlsplit from urllib.request import urlopen -from telemetry_common import HEARTBEAT_REGISTER, ParameterMapping +from telemetry_common import HEARTBEAT_REGISTER, ModelVersion, ParameterMapping # The gateway owns the conversion from model fields to the existing Modbus map. # Types and units are defined by parameterType metadata, not inferred here. @@ -48,9 +48,10 @@ @dataclass(frozen=True) class ModelBatch: - """A checked model instant and all mapped readings at that instant.""" + """A checked model instant, the model that produced it, and its readings.""" simulated_at: datetime + model_version: ModelVersion telemetry: list[dict[str, object]] @@ -159,8 +160,7 @@ def parse_model_snapshot( root = _object(payload, "snapshot") if not _source_is_model(root): raise ValueError("Unexpected model source") - if not isinstance(root.get("modelVersion"), str) or not root["modelVersion"]: - raise ValueError("Missing model version") + model_version = ModelVersion.parse(root.get("modelVersion")) wellheads = root.get("wellheads") if not isinstance(wellheads, list) or not expected_ids: raise ValueError("Missing model fleet") @@ -182,7 +182,7 @@ def parse_model_snapshot( age = (now - common_time).total_seconds() if age < -2 or age > max_age_seconds: raise ValueError("Model snapshot is stale or in the future") - return ModelBatch(common_time, telemetry) + return ModelBatch(common_time, model_version, telemetry) def fetch_model_snapshot( diff --git a/data/src/schema.py b/data/src/schema.py index bf0dc0f..b97b98c 100644 --- a/data/src/schema.py +++ b/data/src/schema.py @@ -1,23 +1,11 @@ -"""Apply ordered SQL migrations to the historian database. - -Replaces mounting the schema into /docker-entrypoint-initdb.d, which Postgres -runs only on first initialisation of the data volume. Under that arrangement -every schema change required `docker compose down -v` and destroyed the -historian, which was documented nowhere. - -Applied migrations are recorded in schema_migrations with a checksum, so a -re-run is a no-op and an edit to an already-applied file is an error rather -than a silent divergence. -""" +"""Install, check, or adopt the canonical historian schema and provision services.""" from __future__ import annotations -import hashlib +import argparse import os -import re import secrets import sys -from dataclasses import dataclass from pathlib import Path import bcrypt @@ -25,200 +13,30 @@ import psycopg2.extensions from psycopg2 import sql +from schema_engine import accept_engine +from schema_extend import extend_schema +from schema_install import ( + SchemaContract, + adopt, + apply_seeds, + build_reference, + check_installed, + has_table, + install_fresh, + installer_lock, +) +from schema_sql import SqlFile, discover from telemetry_common import DatabaseSettings, configure_logging, connect_db -MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "sql" / "migrations" -SEEDS_DIR = Path(__file__).resolve().parent.parent / "sql" / "seeds" - -# TimescaleDB refuses to create a continuous aggregate inside a transaction -# block, so those files opt out of the surrounding transaction. -NO_TRANSACTION_DIRECTIVE = "-- migrate:no-transaction" - -# Bootstrap administrator. The password is generated on first run, never stored -# in the repository, and logged exactly once. +SQL_DIR = Path(__file__).resolve().parent.parent / "sql" +SCHEMA_PATH = SQL_DIR / "schema.sql" +CONTRACT_PATH = SQL_DIR / "schema-contract.json" +SEEDS_DIR = SQL_DIR / "seeds" ADMIN_EMAIL = "admin@example.com" ADMIN_USERNAME = "bootstrap-admin" ADMIN_PASSWORD_BYTES = 18 BCRYPT_ROUNDS = 11 - -SCHEMA_MIGRATIONS_DDL = """ -CREATE TABLE IF NOT EXISTS schema_migrations ( - filename TEXT PRIMARY KEY, - checksum TEXT NOT NULL, - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -) -""" - -logger = configure_logging("migrator", stream=sys.stdout) - - -@dataclass(frozen=True) -class Migration: - """One migration file and how it must be applied.""" - - path: Path - sql: str - checksum: str - - @property - def name(self) -> str: - """Return the filename used as the applied-migration key.""" - return self.path.name - - @property - def in_transaction(self) -> bool: - """Whether this file may be wrapped in a transaction.""" - return NO_TRANSACTION_DIRECTIVE not in self.sql - - @classmethod - def load(cls, path: Path) -> Migration: - """Read a migration file and compute its checksum.""" - sql = path.read_text(encoding="utf-8") - return cls( - path=path, - sql=sql, - checksum=hashlib.sha256(sql.encode("utf-8")).hexdigest(), - ) - - -DOLLAR_TAG_PATTERN = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$") - - -def _end_of_region(sql: str, start: int, terminator: str) -> int: - """Return the index just past `terminator`, or the end of the script.""" - found = sql.find(terminator, start) - return len(sql) if found == -1 else found + len(terminator) - - -def _skip_region(sql: str, index: int) -> int | None: - """If a non-splittable region starts at `index`, return the index past it. - - Comments, string literals, and dollar-quoted bodies may all contain - semicolons that are not statement separators. - """ - rest = sql[index:] - - if rest.startswith("--"): - return _end_of_region(sql, index, "\n") - if rest.startswith("/*"): - return _end_of_region(sql, index + 2, "*/") - if rest.startswith("'"): - return _end_of_region(sql, index + 1, "'") - - tag = DOLLAR_TAG_PATTERN.match(rest) - if tag: - return _end_of_region(sql, index + len(tag.group(0)), tag.group(0)) - - return None - - -def split_statements(sql: str) -> list[str]: - """Split a script into individual statements on top-level semicolons. - - Needed for files applied outside a transaction: Postgres wraps a - multi-statement simple query in an implicit transaction block, which is - exactly what a continuous aggregate refuses to be created inside. Sending - one statement per round trip avoids that. - """ - statements: list[str] = [] - start = 0 - index = 0 - - while index < len(sql): - skipped = _skip_region(sql, index) - if skipped is not None: - index = skipped - continue - - if sql[index] == ";": - statement = sql[start:index].strip() - if statement: - statements.append(statement) - index += 1 - start = index - continue - - index += 1 - - trailing = sql[start:].strip() - if trailing: - statements.append(trailing) - - return statements - - -def discover(directory: Path) -> list[Migration]: - """Return every .sql file in a directory, in filename order.""" - if not directory.is_dir(): - return [] - return [Migration.load(path) for path in sorted(directory.glob("*.sql"))] - - -def applied_migrations(conn: psycopg2.extensions.connection) -> dict[str, str]: - """Return the filename to checksum map of already-applied migrations.""" - with conn.cursor() as cursor: - cursor.execute(SCHEMA_MIGRATIONS_DDL) - conn.commit() - cursor.execute("SELECT filename, checksum FROM schema_migrations") - return dict(cursor.fetchall()) - - -def apply(conn: psycopg2.extensions.connection, migration: Migration) -> None: - """Apply one migration and record it.""" - # psycopg2 opens a transaction implicitly on the first statement, and - # autocommit cannot be changed while one is open. Everything before this - # point is already committed, so closing the idle transaction is safe. - conn.rollback() - - previous_autocommit = conn.autocommit - conn.autocommit = not migration.in_transaction - try: - with conn.cursor() as cursor: - if migration.in_transaction: - cursor.execute(migration.sql) - else: - for statement in split_statements(migration.sql): - cursor.execute(statement) - cursor.execute( - "INSERT INTO schema_migrations (filename, checksum) VALUES (%s, %s)", - (migration.name, migration.checksum), - ) - if migration.in_transaction: - conn.commit() - except Exception: - if migration.in_transaction: - conn.rollback() - raise - finally: - conn.autocommit = previous_autocommit - - -def run(conn: psycopg2.extensions.connection, migrations: list[Migration]) -> int: - """Apply every outstanding migration. Returns how many were applied.""" - already = applied_migrations(conn) - applied_count = 0 - - for migration in migrations: - recorded = already.get(migration.name) - if recorded is not None: - if recorded != migration.checksum: - raise RuntimeError( - f"{migration.name} has changed since it was applied. " - "Migrations are immutable; add a new file instead." - ) - continue - - logger.info( - "Applying migration", - extra={ - "migration": migration.name, - "transactional": migration.in_transaction, - }, - ) - apply(conn, migration) - applied_count += 1 - - return applied_count +logger = configure_logging("schema", stream=sys.stdout) def bootstrap_admin(conn: psycopg2.extensions.connection) -> None: @@ -282,23 +100,13 @@ def bootstrap_admin(conn: psycopg2.extensions.connection) -> None: ) -def provision_twin_reader( - conn: psycopg2.extensions.connection, database: DatabaseSettings +def _provision_service_login( + conn: psycopg2.extensions.connection, + database: DatabaseSettings, + user: str, + password: str, ) -> None: - """Create a read-only login for the internal twin-core service. - - Standalone migration runs without twin settings keep working. Compose sets - both values, so its dependent twin-core always receives a provisioned role. - """ - user = os.getenv("TWIN_DB_USER", "") - password = os.getenv("TWIN_DB_PASSWORD", "") - if not user and not password: - return - if not user or not password: - raise ValueError("TWIN_DB_USER and TWIN_DB_PASSWORD must be set together") - if user == database.user: - raise ValueError("Twin reader must use a separate database role") - + """Reset a separate service login to explicit grants, rejecting powerful roles.""" with conn.cursor() as cursor: cursor.execute( """ @@ -322,13 +130,13 @@ def provision_twin_reader( existing ) if not can_login or any((superuser, create_db, create_role, replication)): - raise ValueError("Twin database role has unexpected privileges") + raise ValueError("Service database role has unexpected privileges") cursor.execute( "SELECT 1 FROM pg_auth_members WHERE member = %s LIMIT 1", (role_id,), ) if cursor.fetchone() is not None: - raise ValueError("Twin database role must not inherit another role") + raise ValueError("Service database role must not inherit another role") cursor.execute( sql.SQL("ALTER ROLE {} PASSWORD %s").format(sql.Identifier(user)), (password,), @@ -336,7 +144,7 @@ def provision_twin_reader( # PostgreSQL 14 grants CREATE on public and TEMPORARY on the database # to PUBLIC by default. Table SELECT grants alone do not make this - # account read-only while those broad grants remain in force. + # account least-privilege while those broad grants remain in force. cursor.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC") cursor.execute( sql.SQL("REVOKE TEMPORARY ON DATABASE {} FROM PUBLIC").format( @@ -348,6 +156,11 @@ def provision_twin_reader( sql.Identifier(user) ) ) + cursor.execute( + sql.SQL("REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM {}").format( + sql.Identifier(user) + ) + ) cursor.execute( sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format( sql.Identifier(database.name), sql.Identifier(user) @@ -356,64 +169,330 @@ def provision_twin_reader( cursor.execute( sql.SQL("GRANT USAGE ON SCHEMA public TO {}").format(sql.Identifier(user)) ) + + +def provision_twin_reader( + conn: psycopg2.extensions.connection, database: DatabaseSettings +) -> None: + """Create a least-privilege login for the internal twin-core service. + + Standalone migration runs without twin settings keep working. Compose sets + both values, so its dependent twin-core always receives a provisioned role. + """ + user = os.getenv("TWIN_DB_USER", "") + password = os.getenv("TWIN_DB_PASSWORD", "") + if not user and not password: + return + if not user or not password: + raise ValueError("TWIN_DB_USER and TWIN_DB_PASSWORD must be set together") + if user == database.user: + raise ValueError("Twin reader must use a separate database role") + + _provision_service_login(conn, database, user, password) + with conn.cursor() as cursor: cursor.execute( sql.SQL( "GRANT SELECT ON TABLE wellhead, deviceParameterMapping, " - "parameterType, parameterReading TO {}" + "parameterType, parameterReading, alarmRule TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT SELECT ON TABLE twin_state_snapshot, " + "wellhead_model_parameter TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT INSERT ON TABLE twin_state_snapshot, twin_recovery_event, " + "prediction_run, prediction_point, scenario_run, scenario_point TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT USAGE ON SEQUENCE twin_state_snapshot_snapshot_id_seq, " + "twin_recovery_event_event_id_seq TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT SELECT ON TABLE control_command, wellhead_control_state TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT UPDATE (status, result_at, result_revision, result_reason) " + "ON control_command TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL("GRANT INSERT, UPDATE ON wellhead_control_state TO {}").format( + sql.Identifier(user) + ) + ) + cursor.execute( + sql.SQL("GRANT INSERT ON control_command_event TO {}").format( + sql.Identifier(user) + ) + ) + cursor.execute( + sql.SQL( + "GRANT USAGE ON SEQUENCE control_command_event_event_id_seq TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT EXECUTE ON FUNCTION control_actor_authority(INT) TO {}" + ).format(sql.Identifier(user)) + ) + with conn.cursor() as cursor: + cursor.execute( + sql.SQL("GRANT SELECT ON forecast_schedule, forecast_job TO {}").format( + sql.Identifier(user) + ) + ) + cursor.execute( + sql.SQL("GRANT INSERT ON forecast_job TO {}").format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT UPDATE (next_due_at, enabled, revision, updated_at) " + "ON forecast_schedule TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT UPDATE (status, completed_at, reason_code, run_id) ON " + "forecast_job TO {}" ).format(sql.Identifier(user)) ) conn.commit() - logger.info("Twin-core read-only database role is ready", extra={"role": user}) + logger.info("Twin-core database role is ready", extra={"role": user}) -def main() -> int: - """Apply schema migrations, then seed fixtures.""" - settings = DatabaseSettings.from_env() +def provision_api_role( + conn: psycopg2.extensions.connection, database: DatabaseSettings +) -> None: + """Grant API writes without schema or active-control ownership.""" + user = os.getenv("API_DB_USER", "") + password = os.getenv("API_DB_PASSWORD", "") + if not user and not password: + return + if not user or not password: + raise ValueError("API_DB_USER and API_DB_PASSWORD must be set together") + if user in (database.user, os.getenv("TWIN_DB_USER", "")): + raise ValueError("API must use a separate database role") + _provision_service_login(conn, database, user, password) + with conn.cursor() as cursor: + cursor.execute( + sql.SQL( + "GRANT SELECT ON users, wellhead, field, location, device, " + "parameterType, deviceParameterMapping, parameterReading, alarmRule, " + "alarmEvent, v_wellhead_parameter_readings, v_active_alarms, " + "mv_2min_pressure_trends, mv_2min_temp_flow_trends, " + "mv_2min_water_cut_gor_trends, mv_5min_alarm_counts, " + "prediction_run, prediction_point, scenario_run, scenario_point, " + "control_command, control_command_event, wellhead_control_state, " + "model_parameter_change, wellhead_model_parameter TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT INSERT ON users, auditEvent, control_command, " + "control_command_event, model_parameter_change, " + "wellhead_model_parameter TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL("GRANT UPDATE (session_version, updated_at) ON users TO {}").format( + sql.Identifier(user) + ) + ) + cursor.execute( + sql.SQL( + "GRANT UPDATE (status, expires_at, queued_at, " + "activated_by, activated_role, " + "activated_session_version, result_at, result_reason) " + "ON control_command TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT UPDATE (status, result_at, published_override_id) " + "ON model_parameter_change TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT USAGE ON SEQUENCE users_id_seq, auditevent_audit_event_id_seq, " + "control_command_event_event_id_seq, " + "wellhead_model_parameter_parameter_override_id_seq TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL("GRANT EXECUTE ON FUNCTION lock_control_state(INT) TO {}").format( + sql.Identifier(user) + ) + ) + with conn.cursor() as cursor: + cursor.execute( + sql.SQL("GRANT SELECT ON forecast_schedule, forecast_job TO {}").format( + sql.Identifier(user) + ) + ) + cursor.execute( + sql.SQL("GRANT INSERT, UPDATE ON forecast_schedule TO {}").format( + sql.Identifier(user) + ) + ) + with conn.cursor() as cursor: + cursor.execute( + sql.SQL( + "GRANT SELECT, INSERT ON alarm_action, " + "alarm_acknowledgement, alarm_shelf TO {}" + ).format(sql.Identifier(user)) + ) + cursor.execute( + sql.SQL( + "GRANT UPDATE (revision, shelved_until, updated_at, action_id) " + "ON alarm_shelf TO {}" + ).format(sql.Identifier(user)) + ) + conn.commit() + logger.info("API database role is ready", extra={"role": user}) + + +def command_parser() -> argparse.ArgumentParser: + """Describe explicit schema operations; init also checks an adopted database.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "mode", + nargs="?", + default="init", + choices=( + "init", + "check", + "adopt", + "build-reference", + "accept-engine", + "extend", + ), + ) + parser.add_argument( + "--without-seeds", + action="store_true", + help="Install structure without demo records or accounts", + ) + parser.add_argument( + "--contract-output", + type=Path, + help="Write a generated reference from a new disposable database", + ) + parser.add_argument( + "--previous-contract", + type=Path, + help="Verified previous reference, required by accept-engine or extend", + ) + parser.add_argument( + "--previous-schema", + type=Path, + help="Exact installed SQL source, required by extend", + ) + return parser - migrations = discover(MIGRATIONS_DIR) - if not migrations: - logger.error("No migrations found", extra={"directory": str(MIGRATIONS_DIR)}) - return 1 - try: - conn = connect_db(settings) - except psycopg2.OperationalError: - logger.exception("Database connection failed") - return 1 +def provision(conn: psycopg2.extensions.connection, settings: DatabaseSettings) -> None: + """Apply configured service grants after structural verification.""" + provision_twin_reader(conn, settings) + provision_api_role(conn, settings) - try: - applied = run(conn, migrations) - seeded = run(conn, discover(SEEDS_DIR)) - except (psycopg2.Error, RuntimeError): - logger.exception("Migration failed") - return 1 - try: +def validate_mode_arguments(args: argparse.Namespace) -> None: + """Require explicit previous references for schema identity changes.""" + if (args.mode in ("accept-engine", "extend")) != ( + args.previous_contract is not None + ): + raise ValueError( + "--previous-contract is required only for accept-engine or extend" + ) + if (args.mode == "extend") != (args.previous_schema is not None): + raise ValueError("--previous-schema is required only for extend") + + +def execute_mode( + conn: psycopg2.extensions.connection, + settings: DatabaseSettings, + args: argparse.Namespace, +) -> None: + """Dispatch the explicit database action while holding the installer lock.""" + source = SqlFile.load(SCHEMA_PATH) + seeds = discover(SEEDS_DIR) + validate_mode_arguments(args) + if args.mode == "build-reference": + if args.contract_output is None: + raise ValueError("build-reference requires --contract-output") + reference = build_reference(conn, source, CONTRACT_PATH) + args.contract_output.write_text(reference.document(), encoding="utf-8") + logger.info("Generated schema reference from a fresh database") + return + if args.contract_output is not None: + raise ValueError("--contract-output is only valid with build-reference") + contract = SchemaContract.load(CONTRACT_PATH, source) + if args.mode == "accept-engine": + previous = SchemaContract.load(args.previous_contract, source) + accept_engine(conn, previous, contract, seeds) + logger.info("Verified engine change accepted and audited") + return + if args.mode == "extend": + previous_source = SqlFile.load(args.previous_schema) + previous = SchemaContract.load(args.previous_contract, previous_source) + extend_schema(conn, previous_source, source, previous, contract, seeds) + logger.info("Schema additions verified and audited") + elif args.mode == "adopt": + adopt(conn, source, contract, seeds) + elif args.mode == "check" or has_table(conn, "schema_installation"): + check_installed(conn, contract, seeds) + else: + install_fresh(conn, source, contract) + if args.mode == "check": + logger.info("Schema identity and structure verified") + return + # Role revocations and grants must commit together. Schema statements use + # autocommit for TimescaleDB, but account provisioning uses transactions. + conn.autocommit = False + if not args.without_seeds: + apply_seeds(conn, seeds) bootstrap_admin(conn) - except psycopg2.Error: - # The schema is applied and correct; only the convenience account - # failed. Blocking every dependent service on that would be wrong. - logger.exception("Bootstrap administrator could not be created") - conn.rollback() + provision(conn, settings) + logger.info("Database schema and configured roles are ready") + +def main() -> int: + """Run the installer and close its connection on every outcome.""" + args = command_parser().parse_args() + conn = None try: - provision_twin_reader(conn, settings) - except (psycopg2.Error, ValueError): - logger.error("Twin-core database role could not be provisioned") - conn.rollback() + settings = DatabaseSettings.from_env() + conn = connect_db(settings) + with installer_lock(conn): + execute_mode(conn, settings, args) + except psycopg2.Error as exc: + # Database errors can contain SQL values. Report the error class and + # SQLSTATE, keeping credentials and application data out of logs. + logger.error( + "Schema database operation failed", + extra={ + "error_type": type(exc).__name__, + "sqlstate": exc.pgcode, + }, + ) + return 1 + except (RuntimeError, ValueError, OSError) as exc: + logger.error("Schema operation refused", extra={"reason": str(exc)}) return 1 finally: - if not conn.closed: + if conn is not None: conn.close() - - logger.info( - "Database is up to date", - extra={ - "migrations_applied": applied, - "seeds_applied": seeded, - "migrations_total": len(migrations), - }, - ) return 0 diff --git a/data/src/schema_catalog.py b/data/src/schema_catalog.py new file mode 100644 index 0000000..dbdd646 --- /dev/null +++ b/data/src/schema_catalog.py @@ -0,0 +1,198 @@ +"""Compare application structure without reading or changing application records. + +The reference is generated from schema.sql on a disposable database. Generated +TimescaleDB IDs and operational timestamps are excluded; policy settings and +logical object names remain part of the comparison. +""" + +from __future__ import annotations + +import json + +import psycopg2.extensions +from psycopg2 import sql + +from schema_normalize import comparison_rows + +Catalog = dict[str, list[str]] + +# Installer bookkeeping is checked by the installer, outside the application +# contract. Extension-owned functions are managed by the pinned extension. +RELATIONS = """ + SELECT c.oid FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND c.relkind NOT IN ('i', 'I') + AND c.relname NOT IN ('schema_migrations', 'schema_installation') + AND NOT EXISTS (SELECT 1 FROM pg_depend d + WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid + AND d.deptype = 'e') +""" + +QUERIES: dict[str, str] = { + "relations": """ + SELECT relname, relkind, relpersistence, relrowsecurity, relforcerowsecurity + FROM pg_class WHERE oid IN ({RELATIONS}) + """, + "columns": """ + SELECT c.relname, a.attname, a.attnum, format_type(a.atttypid, a.atttypmod), + a.attnotnull, a.attidentity, a.attgenerated, + pg_get_expr(d.adbin, d.adrelid), coll.collname + FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid + LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum + LEFT JOIN pg_collation coll ON coll.oid = a.attcollation + WHERE c.oid IN ({RELATIONS}) AND c.relkind IN ('r','p','v','m') + AND a.attnum > 0 AND NOT a.attisdropped + """, + "constraints": """ + SELECT c.relname, k.conname, pg_get_constraintdef(k.oid), k.convalidated + FROM pg_constraint k JOIN pg_class c ON c.oid = k.conrelid + WHERE c.oid IN ({RELATIONS}) + """, + "indexes": """ + SELECT t.relname, c.relname, pg_get_indexdef(i.indexrelid), + i.indisvalid, i.indisready, i.indisreplident + FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_class t ON t.oid = i.indrelid + WHERE t.oid IN ({RELATIONS}) + """, + "triggers": """ + SELECT c.relname, t.tgname, + coalesce(replace(pg_get_triggerdef(t.oid), + '(' || quote_literal(h.id::text) || ')', + '(' || quote_literal(h.schema_name || '.' || h.table_name) || ')'), + pg_get_triggerdef(t.oid)), t.tgenabled + FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid + LEFT JOIN _timescaledb_catalog.hypertable h + ON t.tgfoid = to_regprocedure( + '_timescaledb_functions.continuous_agg_invalidation_trigger()') + AND t.tgargs = convert_to(h.id::text, 'UTF8') || decode('00', 'hex') + WHERE c.oid IN ({RELATIONS}) AND NOT t.tgisinternal + """, + "functions": """ + SELECT p.proname, pg_get_function_identity_arguments(p.oid), + pg_get_functiondef(p.oid), + EXISTS (SELECT 1 FROM aclexplode( + coalesce(p.proacl, acldefault('f', p.proowner))) acl + WHERE acl.grantee = 0 AND acl.privilege_type = 'EXECUTE') + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND NOT EXISTS ( + SELECT 1 FROM pg_depend d WHERE d.classid = 'pg_proc'::regclass + AND d.objid = p.oid AND d.deptype = 'e') + """, + "types": """ + SELECT t.typname, t.typtype, format_type(t.typbasetype, t.typtypmod), + t.typnotnull, t.typdefault, + ARRAY(SELECT e.enumlabel FROM pg_enum e + WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder), + ARRAY(SELECT pg_get_constraintdef(k.oid) FROM pg_constraint k + WHERE k.contypid = t.oid ORDER BY k.conname) + FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE n.nspname = 'public' AND t.typtype IN ('e','d') + """, + "views": """ + SELECT c.relname, pg_get_viewdef(c.oid), c.reloptions + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relkind IN ('v','m') + AND c.relname NOT IN ( + SELECT view_name FROM timescaledb_information.continuous_aggregates + WHERE view_schema = 'public') + """, + "sequences": """ + SELECT c.relname, format_type(s.seqtypid, NULL), s.seqstart, + s.seqincrement, s.seqmax, s.seqmin, s.seqcache, s.seqcycle, + t.relname, a.attname + FROM pg_sequence s JOIN pg_class c ON c.oid = s.seqrelid + LEFT JOIN pg_depend d ON d.classid = 'pg_class'::regclass + AND d.objid = c.oid AND d.deptype IN ('a','i') + LEFT JOIN pg_class t ON t.oid = d.refobjid + LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid + WHERE c.oid IN ({RELATIONS}) + """, + "row_policies": """ + SELECT tablename, policyname, permissive, roles, cmd, qual, with_check + FROM pg_policies WHERE schemaname = 'public' + """, + "hypertables": """ + SELECT hypertable_name, num_dimensions, compression_enabled + FROM timescaledb_information.hypertables WHERE hypertable_schema = 'public' + """, + "dimensions": """ + SELECT hypertable_name, dimension_number, column_name, column_type, + dimension_type, time_interval::text, integer_interval, + integer_now_func, num_partitions + FROM timescaledb_information.dimensions WHERE hypertable_schema = 'public' + """, + "compression": """ + SELECT hypertable_name, attname, segmentby_column_index, + orderby_column_index, orderby_asc, orderby_nullsfirst + FROM timescaledb_information.compression_settings + WHERE hypertable_schema = 'public' + """, + # Recent TimescaleDB versions support only finalized aggregates and remove + # that information column. Older engines still expose and check the flag. + "aggregates": """ + SELECT view_name, hypertable_schema, hypertable_name, materialized_only, + compression_enabled, view_definition, + coalesce((to_jsonb(a)->>'finalized')::boolean, true) + FROM timescaledb_information.continuous_aggregates a + WHERE view_schema = 'public' + """, + "jobs": """ + SELECT coalesce(a.view_name, j.hypertable_name), + j.proc_schema, j.proc_name, j.schedule_interval::text, + j.max_runtime::text, j.max_retries, j.retry_period::text, + j.scheduled, j.fixed_schedule, + j.config - 'hypertable_id' - 'mat_hypertable_id', + j.check_schema, j.check_name + FROM timescaledb_information.jobs j + LEFT JOIN timescaledb_information.continuous_aggregates a + ON a.materialization_hypertable_schema = j.hypertable_schema + AND a.materialization_hypertable_name = j.hypertable_name + WHERE j.hypertable_schema = 'public' OR a.view_schema = 'public' + """, +} + + +def database_versions(conn: psycopg2.extensions.connection) -> dict[str, str]: + """Return the versions used to interpret catalog definitions.""" + with conn.cursor() as cursor: + cursor.execute("SHOW server_version_num") + version = cursor.fetchone() + if version is None: + raise RuntimeError("PostgreSQL did not return its version") + major = str(int(version[0]) // 10000) + cursor.execute( + "SELECT extversion FROM pg_extension WHERE extname='timescaledb'" + ) + row = cursor.fetchone() + return {"postgres_major": major, "timescaledb": str(row[0]) if row else "absent"} + + +def read_catalog(conn: psycopg2.extensions.connection) -> Catalog: + """Read deterministic definitions, excluding stored data and generated IDs.""" + result: Catalog = {} + with conn.cursor() as cursor: + cursor.execute("SET search_path = public, pg_catalog") + cursor.execute("SET TIME ZONE 'UTC'") + cursor.execute("SET intervalstyle = 'postgres'") + for section, query in QUERIES.items(): + cursor.execute(sql.SQL(query).format(RELATIONS=sql.SQL(RELATIONS))) + result[section] = sorted( + json.dumps(list(row), sort_keys=True, default=str) + for row in cursor.fetchall() + ) + return result + + +def differences(expected: Catalog, actual: Catalog) -> list[str]: + """Describe changed object identities without logging function bodies or data.""" + changes: list[str] = [] + for section in sorted(expected.keys() | actual.keys()): + before = comparison_rows(section, expected.get(section, [])) + after = comparison_rows(section, actual.get(section, [])) + if before != after: + names = sorted( + {str(json.loads(row)[0]) for row in set(before) ^ set(after)} + ) + changes.append(f"{section}: {', '.join(names) or 'section differs'}") + return changes diff --git a/data/src/schema_engine.py b/data/src/schema_engine.py new file mode 100644 index 0000000..e4ccf08 --- /dev/null +++ b/data/src/schema_engine.py @@ -0,0 +1,69 @@ +"""Accept a separately verified engine upgrade without applying application DDL.""" + +from __future__ import annotations + +import psycopg2.extensions +from psycopg2.extras import Json + +from schema_install import SchemaContract, check_installed, verify_structure +from schema_sql import SqlFile + + +def accept_engine( + conn: psycopg2.extensions.connection, + previous: SchemaContract, + target: SchemaContract, + seeds: list[SqlFile], +) -> None: + """Verify the new engine and atomically record its identity and audit event. + + The caller must hold the installer lock. The previous generated reference + must match the installed identity. Both references must describe the same + SQL source and legacy history. Engine upgrade and backup verification happen + separately; this operation cannot substitute for those release checks. + """ + if ( + previous.source_checksum != target.source_checksum + or previous.legacy_checksums != target.legacy_checksums + ): + raise RuntimeError("Engine acceptance cannot change SQL source or history") + if previous.versions == target.versions: + raise RuntimeError("Engine acceptance requires a changed engine version") + # Check the target's actual structure before accepting any changed identity. + verify_structure(conn, target) + with conn.cursor() as cursor: + cursor.execute( + "SELECT source_checksum, contract_checksum, status " + "FROM public.schema_installation" + ) + rows = cursor.fetchall() + if rows == [(target.source_checksum, target.checksum, "ready")]: + check_installed(conn, target, seeds) + return + if rows != [(previous.source_checksum, previous.checksum, "ready")]: + raise RuntimeError("Previous reference does not match the installed baseline") + # Only the engine versions and their generated catalog descriptions may + # differ. The regular checker still enforces the complete legacy history. + detail = { + "previous_versions": previous.versions, + "target_versions": target.versions, + "previous_contract": previous.checksum, + "target_contract": target.checksum, + "source_checksum": target.source_checksum, + } + with conn, conn.cursor() as cursor: + cursor.execute( + "UPDATE public.schema_installation SET contract_checksum=%s " + "WHERE singleton AND contract_checksum=%s AND status='ready'", + (target.checksum, previous.checksum), + ) + if cursor.rowcount != 1: + raise RuntimeError("Installed baseline changed during engine acceptance") + check_installed(conn, target, seeds) + cursor.execute( + "INSERT INTO public.auditevent " + "(actor_identifier, action, subject_type, subject_id, outcome, detail) " + "VALUES (current_user, 'schema.engine.accepted', 'database', " + "current_database(), 'success', %s)", + (Json(detail),), + ) diff --git a/data/src/schema_extend.py b/data/src/schema_extend.py new file mode 100644 index 0000000..0863dd6 --- /dev/null +++ b/data/src/schema_extend.py @@ -0,0 +1,163 @@ +"""Add reviewed new tables from the canonical SQL without replaying old DDL.""" + +from __future__ import annotations + +import json +import re + +import psycopg2.extensions +from psycopg2.extras import Json + +from schema_install import SchemaContract, check_installed, verify_structure +from schema_sql import SqlFile, skip_region, split_statements + +IDENTIFIER = r"[a-zA-Z_][a-zA-Z0-9_]{0,62}" +TABLE = re.compile(rf"CREATE\s+TABLE\s+(?:public\.)?({IDENTIFIER})\s*\(", re.I) +INDEX = re.compile( + rf"CREATE\s+(?:UNIQUE\s+)?INDEX\s+({IDENTIFIER})\s+ON\s+" + rf"(?:public\.)?({IDENTIFIER})\s*\(", + re.I, +) + + +def _without_leading_comments(statement: str) -> str: + """Ignore comments at the statement boundary, never quoted SQL values.""" + remaining = statement.lstrip() + while remaining.startswith(("--", "/*")): + end = skip_region(remaining, 0) + if end is None: + raise ValueError("Invalid schema comment") + remaining = remaining[end:].lstrip() + return remaining + + +def _ordinary_table(statement: str, opening: int) -> bool: + """Require one parenthesized definition without inheritance or partition tails.""" + depth = 0 + index = opening + while index < len(statement): + end = skip_region(statement, index) + if end is not None: + index = end + continue + if statement[index] == "(": + depth += 1 + elif statement[index] == ")": + depth -= 1 + if depth == 0: + return not _without_leading_comments(statement[index + 1 :]) + index += 1 + return False + + +def extension_statements( + previous: SqlFile, target: SqlFile, contract: SchemaContract +) -> tuple[list[str], list[str]]: + """Accept only new ordinary tables and indexes on tables in this extension.""" + if not target.sql.startswith(previous.sql) or target.sql == previous.sql: + raise RuntimeError("Extension requires the exact previous schema prefix") + existing = {json.loads(row)[0] for row in contract.catalog["relations"]} | { + json.loads(row)[1] for row in contract.catalog["indexes"] + } + names = set(existing) + tables: list[str] = [] + statements: list[str] = [] + for raw in split_statements(target.sql[len(previous.sql) :]): + statement = _without_leading_comments(raw) + if not statement: + continue + table = TABLE.match(statement) + index = INDEX.match(statement) + if table is not None: + name = table[1].lower() + if not _ordinary_table(statement, table.end() - 1): + raise RuntimeError("Only ordinary new table definitions are supported") + tables.append(name) + elif index is not None and index[2].lower() in tables: + name = index[1].lower() + else: + raise RuntimeError("Extension permits only new tables and their indexes") + if name in names: + raise RuntimeError("Extension object name already exists") + names.add(name) + statements.append(statement) + if not tables: + raise RuntimeError("Extension must add at least one table") + return statements, tables + + +def extend_schema( + conn: psycopg2.extensions.connection, + previous_source: SqlFile, + source: SqlFile, + previous: SchemaContract, + target: SchemaContract, + seeds: list[SqlFile], +) -> None: + """Verify, extend, compare and audit within the caller's installer lock.""" + if ( + previous.versions != target.versions + or previous.legacy_checksums != target.legacy_checksums + or previous.source_checksum != previous_source.checksum + or target.source_checksum != source.checksum + ): + raise RuntimeError("Schema extension cannot change engines or legacy history") + statements, tables = extension_statements(previous_source, source, previous) + if any( + not set(rows) <= set(target.catalog[section]) + for section, rows in previous.catalog.items() + ): + raise RuntimeError("Target contract changes an existing object definition") + with conn.cursor() as cursor: + cursor.execute( + "SELECT source_checksum, contract_checksum, status " + "FROM public.schema_installation" + ) + installed = cursor.fetchall() + if installed == [(target.source_checksum, target.checksum, "ready")]: + check_installed(conn, target, seeds) + return + check_installed(conn, previous, seeds) + autocommit = conn.autocommit + conn.autocommit = False + try: + with conn, conn.cursor() as cursor: + cursor.execute("SET LOCAL lock_timeout = '3000ms'") + cursor.execute("SET LOCAL statement_timeout = '30000ms'") + for statement in statements: + cursor.execute(statement) + verify_structure(conn, target) + cursor.execute( + "UPDATE public.schema_installation SET source_checksum=%s, " + "contract_checksum=%s, completed_at=clock_timestamp(), " + "completed_statements=%s WHERE singleton AND " + "source_checksum=%s AND contract_checksum=%s AND status='ready'", + ( + source.checksum, + target.checksum, + len(split_statements(source.sql)), + previous.source_checksum, + previous.checksum, + ), + ) + if cursor.rowcount != 1: + raise RuntimeError("Installed identity changed during extension") + cursor.execute( + "INSERT INTO public.auditevent " + "(actor_identifier, action, subject_type, subject_id, outcome, detail) " + "VALUES (current_user, 'schema.extended', 'database', " + "current_database(), 'success', %s)", + ( + Json( + { + "previous_source": previous.source_checksum, + "target_source": target.source_checksum, + "previous_contract": previous.checksum, + "target_contract": target.checksum, + "tables": tables, + } + ), + ), + ) + finally: + conn.autocommit = autocommit diff --git a/data/src/schema_install.py b/data/src/schema_install.py new file mode 100644 index 0000000..e265d42 --- /dev/null +++ b/data/src/schema_install.py @@ -0,0 +1,352 @@ +"""Install one schema or adopt a verified legacy database without replaying DDL.""" + +from __future__ import annotations + +import hashlib +import json +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +import psycopg2.extensions + +from schema_catalog import ( + QUERIES, + Catalog, + database_versions, + differences, + read_catalog, +) +from schema_sql import SqlFile, split_statements + +LOCK_ID = 846291735 # Shared by every cooperating installer for this database. +STATE_DDL = """ +CREATE TABLE public.schema_installation ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + source_checksum TEXT NOT NULL, + contract_checksum TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('installing', 'ready')), + method TEXT NOT NULL CHECK (method IN ('init', 'adopt', 'reference')), + completed_statements INTEGER NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + completed_at TIMESTAMPTZ, + installed_by TEXT NOT NULL DEFAULT current_user +) +""" +LEDGER_DDL = """ +CREATE TABLE IF NOT EXISTS public.schema_migrations ( + filename TEXT PRIMARY KEY, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +""" + + +def _string_map(value: object) -> dict[str, str]: + """Validate generated identity maps before using their entries.""" + if not isinstance(value, dict) or not all( + isinstance(key, str) and isinstance(item, str) for key, item in value.items() + ): + raise ValueError("Invalid schema reference identity map") + return cast(dict[str, str], value) + + +def _catalog(value: object) -> Catalog: + """Require every supported comparison section and valid serialized rows.""" + if not isinstance(value, dict) or value.keys() != QUERIES.keys(): + raise ValueError("Schema reference comparison sections differ") + for rows in value.values(): + if not isinstance(rows, list): + raise ValueError("Invalid schema reference rows") + for row in rows: + if not isinstance(row, str): + raise ValueError("Invalid schema reference row") + decoded = json.loads(row) + if not isinstance(decoded, list) or not decoded: + raise ValueError("Invalid schema reference row") + return cast(Catalog, value) + + +@dataclass(frozen=True) +class SchemaContract: + """Generated reference plus the exact legacy history eligible for adoption.""" + + source_checksum: str + versions: dict[str, str] + legacy_checksums: dict[str, str] + catalog: Catalog + + def document(self) -> str: + """Serialize the generated record deterministically for review.""" + return ( + json.dumps( + { + "format": 1, + "source_checksum": self.source_checksum, + "versions": self.versions, + "legacy_checksums": self.legacy_checksums, + "catalog": self.catalog, + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + + @property + def checksum(self) -> str: + """Identify the complete reference, including the comparison results.""" + return hashlib.sha256(self.document().encode()).hexdigest() + + @classmethod + def load(cls, path: Path, source: SqlFile) -> SchemaContract: + """Reject stale or malformed generated references before database writes.""" + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict) or raw.get("format") != 1: + raise RuntimeError("Unsupported schema contract format") + for key in ("versions", "legacy_checksums", "catalog"): + if not isinstance(raw.get(key), dict): + raise RuntimeError(f"Invalid schema contract: {key}") + if raw.get("source_checksum") != source.checksum: + raise RuntimeError("schema.sql changed; rebuild and verify its reference") + return cls( + source.checksum, + _string_map(raw["versions"]), + _string_map(raw["legacy_checksums"]), + _catalog(raw["catalog"]), + ) + + +@contextmanager +def installer_lock( + conn: psycopg2.extensions.connection, timeout_seconds: float = 10.0 +) -> Iterator[None]: + """Hold a session lock across commits, with a bounded acquisition wait.""" + conn.autocommit = True + deadline = time.monotonic() + timeout_seconds + with conn.cursor() as cursor: + cursor.execute("SET lock_timeout = '10s'") + cursor.execute("SET statement_timeout = '60s'") + while True: + cursor.execute("SELECT pg_try_advisory_lock(%s)", (LOCK_ID,)) + acquired = cursor.fetchone() + if acquired is not None and acquired[0]: + break + if time.monotonic() >= deadline: + raise RuntimeError("Another schema installer holds the database lock") + time.sleep(min(0.1, max(0, deadline - time.monotonic()))) + try: + yield + finally: + conn.rollback() + conn.autocommit = True + with conn.cursor() as cursor: + cursor.execute("SELECT pg_advisory_unlock(%s)", (LOCK_ID,)) + + +def has_table(conn: psycopg2.extensions.connection, name: str) -> bool: + """Look up a bookkeeping table without creating it.""" + with conn.cursor() as cursor: + cursor.execute("SELECT to_regclass(%s) IS NOT NULL", (f"public.{name}",)) + row = cursor.fetchone() + return row is not None and bool(row[0]) + + +def require_empty(conn: psycopg2.extensions.connection) -> None: + """Refuse installation if any user object or installer history exists.""" + with conn.cursor() as cursor: + cursor.execute(""" + SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND NOT EXISTS ( + SELECT 1 FROM pg_depend d WHERE d.classid='pg_class'::regclass + AND d.objid=c.oid AND d.deptype='e') + UNION ALL + SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace + WHERE n.nspname='public' AND NOT EXISTS ( + SELECT 1 FROM pg_depend d WHERE d.classid='pg_proc'::regclass + AND d.objid=p.oid AND d.deptype='e') + UNION ALL + SELECT 1 FROM pg_type t JOIN pg_namespace n ON n.oid=t.typnamespace + WHERE n.nspname='public' AND t.typtype IN ('e','d') LIMIT 1 + """) + if cursor.fetchone() is not None: + raise RuntimeError("Database is not empty; use check or verified adopt") + + +def verify_structure( + conn: psycopg2.extensions.connection, contract: SchemaContract +) -> None: + """Fail on unsupported versions or any unchecked structural difference.""" + if database_versions(conn) != contract.versions: + raise RuntimeError( + "Database versions differ from the verified schema reference" + ) + changes = differences(contract.catalog, read_catalog(conn)) + if changes: + raise RuntimeError("Schema differs: " + "; ".join(changes)) + + +def verify_history( + conn: psycopg2.extensions.connection, + contract: SchemaContract, + seeds: list[SqlFile], + *, + require_legacy: bool, +) -> None: + """Validate existing ledger entries without rewriting their history.""" + if not has_table(conn, "schema_migrations"): + if require_legacy: + raise RuntimeError("Legacy schema history is missing") + return + with conn.cursor() as cursor: + cursor.execute("SELECT filename, checksum FROM public.schema_migrations") + recorded = dict(cursor.fetchall()) + expected = contract.legacy_checksums | {seed.name: seed.checksum for seed in seeds} + for name, checksum in recorded.items(): + if expected.get(name) != checksum: + raise RuntimeError(f"Unknown or changed schema history: {name}") + if require_legacy and not contract.legacy_checksums.keys() <= recorded.keys(): + raise RuntimeError("Legacy schema history is incomplete") + + +def _begin_install( + conn: psycopg2.extensions.connection, source: SqlFile, checksum: str, method: str +) -> None: + """Record an incomplete installation before executing schema statements.""" + with conn.cursor() as cursor: + cursor.execute(STATE_DDL) + cursor.execute( + "INSERT INTO public.schema_installation " + "(source_checksum, contract_checksum, status, method) " + "VALUES (%s, %s, 'installing', %s)", + (source.checksum, checksum, method), + ) + + +def _mark_ready(conn: psycopg2.extensions.connection, checksum: str) -> None: + """Finish only after structural verification succeeds.""" + with conn.cursor() as cursor: + cursor.execute( + "UPDATE public.schema_installation SET status='ready', " + "contract_checksum=%s, completed_at=clock_timestamp() WHERE singleton", + (checksum,), + ) + + +def install_fresh( + conn: psycopg2.extensions.connection, + source: SqlFile, + contract: SchemaContract | None, +) -> None: + """Apply each statement once; interrupted installations require investigation.""" + statements = split_statements(source.sql) + require_empty(conn) + if contract is not None: + versions = database_versions(conn) + if versions["timescaledb"] == "absent": + with conn.cursor() as cursor: + cursor.execute( + "SELECT default_version FROM pg_available_extensions " + "WHERE name='timescaledb'" + ) + available = cursor.fetchone() + versions["timescaledb"] = str(available[0]) if available else "absent" + if versions != contract.versions: + raise RuntimeError("Database versions differ from the schema reference") + with conn: + _begin_install( + conn, + source, + contract.checksum if contract else "", + "init" if contract else "reference", + ) + conn.autocommit = True + with conn.cursor() as cursor: + for number, statement in enumerate(statements, start=1): + cursor.execute(statement) + cursor.execute( + "UPDATE public.schema_installation SET completed_statements=%s " + "WHERE singleton", + (number,), + ) + if contract is not None: + verify_structure(conn, contract) + _mark_ready(conn, contract.checksum) + + +def check_installed( + conn: psycopg2.extensions.connection, contract: SchemaContract, seeds: list[SqlFile] +) -> None: + """Check adopted identity and actual structure without applying schema SQL.""" + if not has_table(conn, "schema_installation"): + raise RuntimeError( + "No canonical baseline recorded; verify and adopt this database" + ) + with conn.cursor() as cursor: + cursor.execute( + "SELECT source_checksum, contract_checksum, status, method " + "FROM public.schema_installation" + ) + rows = cursor.fetchall() + if len(rows) != 1 or rows[0][2] != "ready": + raise RuntimeError("Schema installation is incomplete; inspect before recovery") + if tuple(rows[0][:2]) != (contract.source_checksum, contract.checksum): + raise RuntimeError("Schema identity changed; a reviewed conversion is required") + verify_history(conn, contract, seeds, require_legacy=rows[0][3] == "adopt") + verify_structure(conn, contract) + + +def adopt( + conn: psycopg2.extensions.connection, + source: SqlFile, + contract: SchemaContract, + seeds: list[SqlFile], +) -> None: + """Record a matching legacy installation, retaining every application object.""" + if has_table(conn, "schema_installation"): + check_installed(conn, contract, seeds) + return + verify_history(conn, contract, seeds, require_legacy=True) + verify_structure(conn, contract) + with conn: + _begin_install(conn, source, contract.checksum, "adopt") + _mark_ready(conn, contract.checksum) + + +def apply_seeds(conn: psycopg2.extensions.connection, seeds: list[SqlFile]) -> int: + """Apply each separate fixture once, recording its unchanged checksum.""" + with conn.cursor() as cursor: + cursor.execute(LEDGER_DDL) + cursor.execute("SELECT filename, checksum FROM public.schema_migrations") + recorded = dict(cursor.fetchall()) + count = 0 + for seed in seeds: + if seed.name in recorded: + if recorded[seed.name] != seed.checksum: + raise RuntimeError(f"Seed changed after application: {seed.name}") + continue + with conn, conn.cursor() as cursor: + cursor.execute(seed.sql) + cursor.execute( + "INSERT INTO public.schema_migrations (filename, checksum) " + "VALUES (%s, %s)", + (seed.name, seed.checksum), + ) + count += 1 + return count + + +def build_reference( + conn: psycopg2.extensions.connection, source: SqlFile, legacy_path: Path +) -> SchemaContract: + """Generate a contract exclusively from a fresh installation of the source.""" + legacy = _string_map(json.loads(legacy_path.read_text())["legacy_checksums"]) + install_fresh(conn, source, None) + contract = SchemaContract( + source.checksum, database_versions(conn), legacy, read_catalog(conn) + ) + _mark_ready(conn, contract.checksum) + return contract diff --git a/data/src/schema_normalize.py b/data/src/schema_normalize.py new file mode 100644 index 0000000..9c5a03c --- /dev/null +++ b/data/src/schema_normalize.py @@ -0,0 +1,88 @@ +"""Compare equivalent PostgreSQL renderings without ignoring SQL value changes.""" + +from __future__ import annotations + +import json +import re + +from schema_sql import skip_region + +# PostgreSQL 14 casts each unbounded varchar literal to text. PostgreSQL 16 can +# cast the entire array instead. Only these two exact forms are interchangeable; +# bounded varchar casts, other types, nulls, and expressions remain untouched. +LITERAL = r"'(?:[^']|'')*'" +VARCHAR = LITERAL + r"::character varying" +ARRAY_CASTS = ( + re.compile(r"\(ARRAY\[(" + VARCHAR + r"(?:, " + VARCHAR + r")*)\]\)::text\[\]"), + re.compile( + r"ARRAY\[(\(" + VARCHAR + r"\)::text(?:, \(" + VARCHAR + r"\)::text)*)\]" + ), +) +VARCHAR_VALUE = re.compile("(" + LITERAL + r")::character varying") + +# BETWEEN expands after parsing into a nested AND. pg_dump emits that expanded +# expression; parsing it during restore flattens the AND. Limit equivalence to +# the verified integer bounds/modulo shape used by forecast durations. +BOUNDED_MINUTES = re.compile( + r"CHECK \(\(\(\((?Pinterval_seconds|horizon_seconds) >= " + r"(?P-?[0-9]+)\) AND \((?P=column) <= (?P-?[0-9]+)\)\)" + r" AND \(\((?P=column) % (?P[0-9]+)\) = " + r"(?P-?[0-9]+)\)\)\)" +) + + +def normalize_bounded_minutes(definition: str) -> str: + """Flatten only the verified whole-expression AND; retain every comparison.""" + match = BOUNDED_MINUTES.fullmatch(definition) + if match is None: + return definition + column, lower, upper, divisor, remainder = match.groups() + return ( + f"CHECK ((({column} >= {lower}) AND ({column} <= {upper}) " + f"AND (({column} % {divisor}) = {remainder})))" + ) + + +def _array_match(definition: str, index: int) -> re.Match[str] | None: + """Recognize either supported array cast at the current SQL position.""" + for pattern in ARRAY_CASTS: + match = pattern.match(definition, index) + if match is not None: + return match + return None + + +def normalize_text_arrays(definition: str) -> str: + """Canonicalize only literal arrays, preserving quoted SQL and comments.""" + result: list[str] = [] + index = 0 + while index < len(definition): + end = skip_region(definition, index) + if end is not None: + result.append(definition[index:end]) + index = end + continue + match = _array_match(definition, index) + if match is None: + result.append(definition[index]) + index += 1 + continue + values = [m.group(1) + "::text" for m in VARCHAR_VALUE.finditer(match.group(1))] + result.append("ARRAY[" + ", ".join(values) + "]") + index = match.end() + return "".join(result) + + +def comparison_rows(section: str, rows: list[str]) -> list[str]: + """Normalize known deparser differences only in constraint and view SQL.""" + position = {"constraints": 2, "aggregates": 5}.get(section) + if position is None: + return sorted(rows) + result: list[str] = [] + for row in rows: + values = json.loads(row) + values[position] = normalize_text_arrays(values[position]) + if section == "constraints": + values[position] = normalize_bounded_minutes(values[position]) + result.append(json.dumps(values, sort_keys=True)) + return sorted(result) diff --git a/data/src/schema_sql.py b/data/src/schema_sql.py new file mode 100644 index 0000000..874ee10 --- /dev/null +++ b/data/src/schema_sql.py @@ -0,0 +1,137 @@ +"""Load SQL files and split top-level statements without splitting function bodies.""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class SqlFile: + """One SQL file and how it must be applied.""" + + path: Path + sql: str + checksum: str + + @property + def name(self) -> str: + """Return the filename used as the applied-SQL file key.""" + return self.path.name + + @classmethod + def load(cls, path: Path) -> SqlFile: + """Read a SQL file and compute its checksum.""" + sql = path.read_text(encoding="utf-8") + return cls( + path=path, + sql=sql, + checksum=hashlib.sha256(sql.encode("utf-8")).hexdigest(), + ) + + +DOLLAR_TAG_PATTERN = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$") + + +def _quoted_end(sql: str, start: int) -> int: + """Skip SQL strings or identifiers, including doubled delimiters.""" + quote = sql[start] + escapes = start > 0 and sql[start - 1] in "eE" and quote == "'" + index = start + 1 + while index < len(sql): + if (escapes and sql[index] == "\\") or sql[index : index + 2] == quote * 2: + index += 2 + elif sql[index] == quote: + return index + 1 + else: + index += 1 + raise ValueError("Unterminated SQL quoted value") + + +def _comment_end(sql: str, start: int) -> int: + """Skip a block comment, including nested comments allowed by PostgreSQL.""" + depth = 1 + index = start + 2 + while index < len(sql): + pair = sql[index : index + 2] + if pair == "/*": + depth += 1 + index += 2 + elif pair == "*/": + depth -= 1 + index += 2 + if depth == 0: + return index + else: + index += 1 + raise ValueError("Unterminated SQL block comment") + + +def skip_region(sql: str, index: int) -> int | None: + """If a non-splittable region starts at `index`, return the index past it. + + Comments, string literals, and dollar-quoted bodies may all contain + semicolons that are not statement separators. + """ + rest = sql[index:] + + if rest.startswith("--"): + end = sql.find("\n", index) + return len(sql) if end == -1 else end + 1 + if rest.startswith("/*"): + return _comment_end(sql, index) + if rest.startswith(("'", '"')): + return _quoted_end(sql, index) + + tag = DOLLAR_TAG_PATTERN.match(rest) + if tag: + end = sql.find(tag.group(0), index + len(tag.group(0))) + if end == -1: + raise ValueError("Unterminated SQL function body") + return end + len(tag.group(0)) + + return None + + +def split_statements(sql: str) -> list[str]: + """Split a script into individual statements on top-level semicolons. + + Needed for files applied outside a transaction: Postgres wraps a + multi-statement simple query in an implicit transaction block, which is + exactly what a continuous aggregate refuses to be created inside. Sending + one statement per round trip avoids that. + """ + statements: list[str] = [] + start = 0 + index = 0 + + while index < len(sql): + skipped = skip_region(sql, index) + if skipped is not None: + index = skipped + continue + + if sql[index] == ";": + statement = sql[start:index].strip() + if statement: + statements.append(statement) + index += 1 + start = index + continue + + index += 1 + + trailing = sql[start:].strip() + if trailing: + statements.append(trailing) + + return statements + + +def discover(directory: Path) -> list[SqlFile]: + """Return every .sql file in a directory, in filename order.""" + if not directory.is_dir(): + return [] + return [SqlFile.load(path) for path in sorted(directory.glob("*.sql"))] diff --git a/data/src/telemetry_common.py b/data/src/telemetry_common.py index 5d897a7..c848696 100644 --- a/data/src/telemetry_common.py +++ b/data/src/telemetry_common.py @@ -25,6 +25,8 @@ import psycopg2.extensions from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from telemetry_metrics import TELEMETRY_READY + # Both the gateway and ingestion encode values as two 16-bit words, big-endian # bytes with the low word first. This reproduces the layout written before the # pymodbus 3.x upgrade, so existing register maps and stored readings stay valid. @@ -36,6 +38,15 @@ # ingestion tell a live pipeline from a stalled one serving stale registers. HEARTBEAT_REGISTER = 1900 +# The model version that produced the register image follows the heartbeat: +# one unsigned 16-bit register each for major (1902), minor (1903), and patch +# (1904). Heartbeat and version form one five-register status block. The +# gateway writes the block in one call and ingestion reads it in one Read +# Holding Registers request, so the two always describe the same update. +MODEL_VERSION_REGISTER = HEARTBEAT_REGISTER + 2 +STATUS_BLOCK_REGISTERS = 5 +_MAX_REGISTER_VALUE = 0xFFFF + _RESERVED_LOG_FIELDS = frozenset( logging.LogRecord("", 0, "", 0, "", None, None).__dict__ ) @@ -169,6 +180,61 @@ def connect_db(settings: DatabaseSettings) -> psycopg2.extensions.connection: ) +@dataclass(frozen=True) +class ModelVersion: + """The twin-core model version, as published in the Modbus status block. + + Twin-core reports text such as "0.3.0". Modbus carries it as three unsigned + 16-bit registers, one per part. Version 0.0.0 is reserved: an all-zero block + means the gateway is mid-update or has never published, exactly as a zero + heartbeat does. + """ + + major: int + minor: int + patch: int + + def __post_init__(self) -> None: + """Reject parts that cannot be one register each, and the reserved zero.""" + parts = (self.major, self.minor, self.patch) + if any( + type(part) is not int or not 0 <= part <= _MAX_REGISTER_VALUE + for part in parts + ): + raise ValueError("Model version parts must be integers from 0 to 65535") + if parts == (0, 0, 0): + raise ValueError("Model version 0.0.0 is reserved for 'not published'") + + @classmethod + def parse(cls, text: object) -> ModelVersion: + """Read "major.minor.patch" text, rejecting anything else.""" + if not isinstance(text, str): + raise ValueError("Model version must be text") + parts = text.split(".") + if len(parts) != 3 or not all( + part.isascii() and part.isdigit() for part in parts + ): + raise ValueError("Model version must look like 0.3.0") + return cls(int(parts[0]), int(parts[1]), int(parts[2])) + + @classmethod + def from_registers(cls, registers: list[int]) -> ModelVersion | None: + """Decode three registers, or return None for the unpublished zero block.""" + if len(registers) != 3: + raise ValueError("A model version occupies exactly three registers") + if registers == [0, 0, 0]: + return None + return cls(registers[0], registers[1], registers[2]) + + def to_registers(self) -> list[int]: + """Encode as three registers in major, minor, patch order.""" + return [self.major, self.minor, self.patch] + + def __str__(self) -> str: + """Return the text form stored in parameterReading.model_version.""" + return f"{self.major}.{self.minor}.{self.patch}" + + @dataclass(frozen=True) class ParameterMapping: """One active parameter on one wellhead, and where to read or write it. @@ -324,6 +390,7 @@ def do_GET(self) -> None: {"status": "ready" if ready else "not_ready", **self.health.detail}, ) elif self.path == "/metrics": + TELEMETRY_READY.set(int(self.health.alive and self.health.ready)) body = generate_latest() self.send_response(200) self.send_header("Content-Type", CONTENT_TYPE_LATEST) diff --git a/data/src/telemetry_metrics.py b/data/src/telemetry_metrics.py index fe7de3e..ed84673 100644 --- a/data/src/telemetry_metrics.py +++ b/data/src/telemetry_metrics.py @@ -19,11 +19,21 @@ "Parameter readings inserted into the historian", ) +LAST_COMMIT_TIMESTAMP = Gauge( + "well_sim_ingestion_last_commit_timestamp_seconds", + "UTC epoch time of the last committed complete reading batch; zero before a commit", +) + POLLS_SKIPPED_STALE = Counter( "wellhead_polls_skipped_stale_total", "Poll cycles skipped because the gateway heartbeat had not advanced", ) +POLL_RETRIES = Counter( + "wellhead_poll_retries_total", + "Polls repeated because a gateway update landed while registers were being read", +) + MODBUS_READ_ERRORS = Counter( "wellhead_modbus_read_errors_total", "Modbus register reads that returned an error", @@ -59,6 +69,11 @@ # Shared ----------------------------------------------------------------- +TELEMETRY_READY = Gauge( + "well_sim_telemetry_ready", + "One when the telemetry service is alive and ready at collection time", +) + METADATA_RELOADS = Counter( "wellhead_metadata_reloads_total", "Metadata reloads, by whether the mappings changed", diff --git a/data/tests/test_forecast_database.py b/data/tests/test_forecast_database.py new file mode 100644 index 0000000..1618744 --- /dev/null +++ b/data/tests/test_forecast_database.py @@ -0,0 +1,273 @@ +"""Exercise claims and atomic results through the restricted model DB role.""" + +from __future__ import annotations + +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from datetime import datetime, timezone +from uuid import uuid4 + +import psycopg2 +import pytest +from psycopg2 import sql +from test_schema_database import database, installed, pytestmark +from twin_core.config import ServiceSettings, load_defaults +from twin_core.database import DatabaseUnavailableError +from twin_core.forecast_store import ForecastClaimError, ForecastStore +from twin_core.model import ControlInputs, ModelParameters, initial_state +from twin_core.persistence import TwinPersistence +from twin_core.projection import ProjectionBase, ProjectionResult, project +from twin_core.recovery import parameter_fingerprint + +from schema import provision_twin_reader +from telemetry_common import DatabaseSettings + +__all__ = ["database", "installed", "pytestmark"] + + +@pytest.fixture +def scheduled( + installed: psycopg2.extensions.connection, monkeypatch: pytest.MonkeyPatch +) -> Iterator[tuple[ForecastStore, TwinPersistence, int]]: + role = "forecast_test_" + uuid4().hex + password = uuid4().hex + monkeypatch.setenv("TWIN_DB_USER", role) + monkeypatch.setenv("TWIN_DB_PASSWORD", password) + settings = ServiceSettings( + bind_host="127.0.0.1", + port=0, + max_catchup_steps=5, + fleet_refresh_seconds=30, + telemetry_freshness_seconds=15, + database_host=installed.info.host, + database_port=installed.info.port, + database_name=installed.info.dbname, + database_user=role, + database_password=password, + environment_overrides={}, + ) + admin = DatabaseSettings( + installed.info.host, + installed.info.port, + installed.info.dbname, + installed.info.user, + "unused", + ) + # Provisioning uses the connected database name for the CONNECT grant. + admin = replace(admin, name=installed.info.dbname) + installed.autocommit = False + provision_twin_reader(installed, admin) + installed.autocommit = True + with installed.cursor() as cur: + cur.execute( + "INSERT INTO users(email,user_name,encrypted_password,role) " + "VALUES('scheduler@test.local','scheduler','unused','ADMIN') RETURNING id" + ) + row = cur.fetchone() + assert row is not None + owner = row[0] + cur.execute( + "INSERT INTO forecast_schedule(schedule_id,wellhead_id,owner_user_id," + "owner_role,enabled,interval_seconds,horizon_seconds,step_seconds," + "revision,next_due_at,reason) VALUES(%s,1,%s,'ADMIN',true,300,300,60,1," + "clock_timestamp()-interval '30 days','Test schedule')", + (str(uuid4()), owner), + ) + try: + yield ForecastStore(settings), TwinPersistence(settings), owner + finally: + with installed.cursor() as cur: + cur.execute(sql.SQL("DROP OWNED BY {}").format(sql.Identifier(role))) + cur.execute(sql.SQL("DROP ROLE {}").format(sql.Identifier(role))) + + +def forecast() -> ProjectionResult: + now = datetime.now(timezone.utc) + defaults = load_defaults() + parameters = ModelParameters.from_values( + {key: value.default for key, value in defaults.parameters.items()} + ) + controls = ControlInputs() + base = ProjectionBase( + 1, + defaults.version, + parameter_fingerprint(parameters), + initial_state(1, parameters, controls, now), + controls, + parameters, + None, + None, + ) + return project("forecast", str(uuid4()), base, now, 300, 60, ()) + + +def test_competing_claims_keep_inputs_after_schedule_edits( + installed: psycopg2.extensions.connection, + scheduled: tuple[ForecastStore, TwinPersistence, int], +) -> None: + jobs, writer, owner = scheduled + with ThreadPoolExecutor(max_workers=2) as pool: + claimed = list(pool.map(lambda _: jobs.claim(), range(2))) + actual = [job for job in claimed if job is not None] + assert len(actual) == 1 + job = actual[0] + assert job.missed_intervals >= 8639 + with installed.cursor() as cur: + cur.execute( + "UPDATE forecast_schedule SET enabled=false, next_due_at=NULL, " + "horizon_seconds=600, revision=2 WHERE schedule_id=%s", + (job.schedule_id,), + ) + # Disabling affects future claims; this job keeps its copied five-minute input. + result = replace(forecast(), scheduled_job=job) + writer.write_projection(result, owner) + assert jobs.fail(job, "uncertain_commit") == "complete" + assert jobs.claim() is None + with installed.cursor() as cur: + cur.execute("SELECT enabled,horizon_seconds,revision FROM forecast_schedule") + assert cur.fetchone() == (False, 600, 2) + cur.execute( + "SELECT status,run_id FROM forecast_job WHERE job_id=%s", (job.job_id,) + ) + assert cur.fetchone() == ("complete", result.run_id) + cur.execute( + "SELECT count(*) FROM prediction_point WHERE run_id=%s", (result.run_id,) + ) + assert cur.fetchone() == (5,) + cur.execute( + "SELECT base_state FROM prediction_run WHERE run_id=%s", (result.run_id,) + ) + row = cur.fetchone() + assert row is not None + assert row[0]["executionOrigin"]["jobId"] == job.job_id + with pytest.raises(ForecastClaimError): + writer.write_projection(replace(result, run_id=str(uuid4())), owner) + + +def test_duplicate_point_failure_rolls_back_result_and_completion( + installed: psycopg2.extensions.connection, + scheduled: tuple[ForecastStore, TwinPersistence, int], +) -> None: + jobs, writer, owner = scheduled + job = jobs.claim() + assert job is not None + result = forecast() + result = replace( + result, scheduled_job=job, points=(result.points[0], result.points[0]) + ) + with pytest.raises(DatabaseUnavailableError): + writer.write_projection(result, owner) + with installed.cursor() as cur: + cur.execute("SELECT status FROM forecast_job WHERE job_id=%s", (job.job_id,)) + assert cur.fetchone() == ("running",) + cur.execute( + "SELECT count(*) FROM prediction_run WHERE run_id=%s", (result.run_id,) + ) + assert cur.fetchone() == (0,) + + +@pytest.mark.parametrize("when", ["before_claim", "before_save"]) +def test_revocation_blocks_work_and_disables_future_claims( + installed: psycopg2.extensions.connection, + scheduled: tuple[ForecastStore, TwinPersistence, int], + when: str, +) -> None: + jobs, writer, owner = scheduled + job = jobs.claim() if when == "before_save" else None + with installed.cursor() as cur: + cur.execute("UPDATE users SET role='USER' WHERE id=%s", (owner,)) + if when == "before_claim": + job = jobs.claim() + assert job is not None and job.status == "skipped" + else: + assert job is not None + with pytest.raises(ForecastClaimError, match="authority_revoked"): + writer.write_projection(replace(forecast(), scheduled_job=job), owner) + assert jobs.fail(job, "authority_revoked") == "failed" + with installed.cursor() as cur: + cur.execute("SELECT enabled,next_due_at FROM forecast_schedule") + assert cur.fetchone() == (False, None) + cur.execute("SELECT count(*) FROM prediction_run") + assert cur.fetchone() == (0,) + + +def test_expired_and_mismatched_claims_cannot_save( + installed: psycopg2.extensions.connection, + scheduled: tuple[ForecastStore, TwinPersistence, int], +) -> None: + jobs, writer, owner = scheduled + job = jobs.claim() + assert job is not None + with pytest.raises(ForecastClaimError): + writer.write_projection( + replace(forecast(), scheduled_job=replace(job, horizon_seconds=600)), owner + ) + with installed.cursor() as cur: + cur.execute( + "UPDATE forecast_job SET started_at=clock_timestamp()-interval '2 minutes'," + "lease_expires_at=clock_timestamp()-interval '1 minute' WHERE job_id=%s", + (job.job_id,), + ) + with pytest.raises(ForecastClaimError): + writer.write_projection(replace(forecast(), scheduled_job=job), owner) + assert jobs.expire() == 1 + assert jobs.expire() == 0 + assert jobs.claim() is None + with installed.cursor() as cur: + cur.execute("SELECT status,reason_code FROM forecast_job") + assert cur.fetchone() == ("failed", "worker_interrupted") + + +def test_expiry_during_result_insert_rolls_back_points_and_job( + installed: psycopg2.extensions.connection, + scheduled: tuple[ForecastStore, TwinPersistence, int], + monkeypatch: pytest.MonkeyPatch, +) -> None: + from twin_core.forecast_job import ScheduledForecastJob + from twin_core.forecast_store import complete_job + + jobs, writer, owner = scheduled + job = jobs.claim() + assert job is not None + result = replace(forecast(), scheduled_job=job) + + with installed.cursor() as cur: + cur.execute( + "UPDATE forecast_job SET lease_expires_at=clock_timestamp() " + "+ interval '1 second' WHERE job_id=%s", + (job.job_id,), + ) + reached_completion = [] + + def expire_before_completion( + cursor: psycopg2.extensions.cursor, + claimed: ScheduledForecastJob, + run_id: str, + ) -> None: + reached_completion.append(True) + # The restricted worker cannot change its lease. Let the database clock + # cross the short fixture deadline after all points were inserted. + cursor.execute( + "SELECT pg_sleep(greatest(0, extract(epoch FROM " + "lease_expires_at-clock_timestamp())) + 0.02) " + "FROM forecast_job WHERE job_id=%s", + (claimed.job_id,), + ) + complete_job(cursor, claimed, run_id) + + monkeypatch.setattr("twin_core.persistence.complete_job", expire_before_completion) + with pytest.raises(ForecastClaimError, match="claim_expired"): + writer.write_projection(result, owner) + assert reached_completion == [True] + with installed.cursor() as cur: + cur.execute("SELECT status FROM forecast_job WHERE job_id=%s", (job.job_id,)) + assert cur.fetchone() == ("running",) + cur.execute( + "SELECT count(*) FROM prediction_point WHERE run_id=%s", (result.run_id,) + ) + assert cur.fetchone() == (0,) + cur.execute( + "SELECT count(*) FROM prediction_run WHERE run_id=%s", (result.run_id,) + ) + assert cur.fetchone() == (0,) diff --git a/data/tests/test_metadata_reload.py b/data/tests/test_metadata_reload.py index 5dd1290..0882702 100644 --- a/data/tests/test_metadata_reload.py +++ b/data/tests/test_metadata_reload.py @@ -7,6 +7,8 @@ from __future__ import annotations +from datetime import datetime, timezone + import psycopg2 import pytest from pytest import MonkeyPatch @@ -14,12 +16,17 @@ import modbus_gateway as gateway from telemetry_common import ( HEARTBEAT_REGISTER, + STATUS_BLOCK_REGISTERS, DatabaseSettings, MetadataReloader, + ModelVersion, ParameterMapping, mappings_changed, ) +NOW = datetime(2026, 10, 2, 12, 0, tzinfo=timezone.utc) +VERSION = ModelVersion(0, 3, 0) + # Never used to connect: every test that touches the loader replaces it. SETTINGS = DatabaseSettings( host="localhost", @@ -127,7 +134,9 @@ def test_a_moved_mapping_leaves_no_value_behind(self) -> None: """ADR 0035: the old address must not keep serving a stale reading.""" store = gateway.RegisterStore([mapping(modbus_register=0)]) store.apply_batch( - [{"wellhead_id": 1, "parameters": {"tubing_pressure": 1234.5}}] + [{"wellhead_id": 1, "parameters": {"tubing_pressure": 1234.5}}], + NOW, + VERSION, ) assert store.context[1].getValues(3, 0, 2) != [0, 0] @@ -136,20 +145,28 @@ def test_a_moved_mapping_leaves_no_value_behind(self) -> None: assert store.context[1].getValues(3, 0, 2) == [0, 0] def test_the_heartbeat_is_cleared_too(self) -> None: - """A surviving heartbeat would tell ingestion the data is fresh.""" + """A surviving heartbeat or version would tell ingestion the data is fresh.""" store = gateway.RegisterStore([mapping()]) - store.apply_batch([{"wellhead_id": 1, "parameters": {}}]) - assert store.context[1].getValues(3, HEARTBEAT_REGISTER, 2) != [0, 0] + store.apply_batch([{"wellhead_id": 1, "parameters": {}}], NOW, VERSION) + block = store.context[1].getValues( + 3, HEARTBEAT_REGISTER, STATUS_BLOCK_REGISTERS + ) + assert block != [0] * STATUS_BLOCK_REGISTERS store.replace_mappings([mapping()]) - assert store.context[1].getValues(3, HEARTBEAT_REGISTER, 2) == [0, 0] + block = store.context[1].getValues( + 3, HEARTBEAT_REGISTER, STATUS_BLOCK_REGISTERS + ) + assert block == [0] * STATUS_BLOCK_REGISTERS def test_the_new_mappings_are_addressable(self) -> None: store = gateway.RegisterStore([mapping(modbus_register=0)]) store.replace_mappings([mapping(modbus_register=200)]) - store.apply_batch([{"wellhead_id": 1, "parameters": {"tubing_pressure": 42.0}}]) + store.apply_batch( + [{"wellhead_id": 1, "parameters": {"tubing_pressure": 42.0}}], NOW, VERSION + ) assert store.context[1].getValues(3, 200, 2) != [0, 0] assert store.context[1].getValues(3, 0, 2) == [0, 0] diff --git a/data/tests/test_model_source.py b/data/tests/test_model_source.py index a91052a..855e811 100644 --- a/data/tests/test_model_source.py +++ b/data/tests/test_model_source.py @@ -9,7 +9,7 @@ import pytest from model_source import parse_model_snapshot, supported_mappings -from telemetry_common import ParameterMapping +from telemetry_common import ModelVersion, ParameterMapping NOW = datetime(2026, 9, 30, 12, 0, tzinfo=timezone.utc) @@ -50,6 +50,7 @@ def snapshot() -> dict[str, Any]: def test_complete_snapshot_maps_all_seeded_values() -> None: batch = parse_model_snapshot(snapshot(), {1}, NOW, 10) assert batch.simulated_at == NOW + assert batch.model_version == ModelVersion(0, 3, 0) params = batch.telemetry[0]["parameters"] assert isinstance(params, dict) assert len(params) == 18 @@ -60,7 +61,17 @@ def test_complete_snapshot_maps_all_seeded_values() -> None: @pytest.mark.parametrize( - "fault", ["missing", "old", "future", "mixed", "nan", "unready"] + "fault", + [ + "missing", + "old", + "future", + "mixed", + "nan", + "unready", + "no_version", + "bad_version", + ], ) def test_rejects_incomplete_or_untrustworthy_model_ticks(fault: str) -> None: payload = snapshot() @@ -79,6 +90,11 @@ def test_rejects_incomplete_or_untrustworthy_model_ticks(fault: str) -> None: payload["wellheads"].append(other) elif fault == "nan": process["flowRateBpd"] = float("nan") + elif fault == "no_version": + del payload["modelVersion"] + elif fault == "bad_version": + # Cannot become three 16-bit registers, so it must not reach Modbus. + payload["modelVersion"] = "0.3.70000" else: well["status"] = "degraded" with pytest.raises(ValueError): diff --git a/data/tests/test_schema.py b/data/tests/test_schema.py index d7e4728..e662f94 100644 --- a/data/tests/test_schema.py +++ b/data/tests/test_schema.py @@ -1,4 +1,4 @@ -"""Tests for the migration runner. +"""Tests for canonical SQL loading and statement splitting. The statement splitter exists because TimescaleDB refuses to create a continuous aggregate inside a transaction block, and Postgres wraps any @@ -10,9 +10,7 @@ from pathlib import Path -import pytest - -from schema import Migration, split_statements +from schema_sql import SqlFile, split_statements class TestSplitStatements: @@ -59,14 +57,9 @@ def test_tagged_dollar_quote_is_kept_whole(self) -> None: assert len(statements) == 2 assert statements[1] == "SELECT 2" - def test_real_continuous_aggregate_migration_splits_cleanly(self) -> None: + def test_canonical_aggregates_split_cleanly(self) -> None: """The file this splitter exists for must produce runnable statements.""" - path = ( - Path(__file__).resolve().parents[1] - / "sql" - / "migrations" - / "0004_continuous_aggregates.sql" - ) + path = Path(__file__).resolve().parents[1] / "sql" / "schema.sql" statements = split_statements(path.read_text(encoding="utf-8")) assert len(statements) > 1 @@ -78,11 +71,11 @@ def test_real_continuous_aggregate_migration_splits_cleanly(self) -> None: assert "GROUP BY" in create -class TestMigration: - def _write(self, tmp_path: Path, name: str, body: str) -> Migration: +class TestSqlFile: + def _write(self, tmp_path: Path, name: str, body: str) -> SqlFile: path = tmp_path / name path.write_text(body, encoding="utf-8") - return Migration.load(path) + return SqlFile.load(path) def test_checksum_changes_with_content(self, tmp_path: Path) -> None: a = self._write(tmp_path, "a.sql", "SELECT 1;") @@ -95,24 +88,3 @@ def test_checksum_is_stable(self, tmp_path: Path) -> None: self._write(tmp_path, "a.sql", body).checksum == self._write(tmp_path, "b.sql", body).checksum ) - - def test_transactional_by_default(self, tmp_path: Path) -> None: - assert self._write(tmp_path, "a.sql", "SELECT 1;").in_transaction is True - - def test_directive_opts_out_of_transaction(self, tmp_path: Path) -> None: - migration = self._write( - tmp_path, "a.sql", "-- migrate:no-transaction\nSELECT 1;" - ) - assert migration.in_transaction is False - - @pytest.mark.parametrize( - "filename", - [ - "0004_continuous_aggregates.sql", - "0005_retention_and_compression.sql", - ], - ) - def test_timescale_files_opt_out(self, filename: str) -> None: - """These must never be wrapped in a transaction; TimescaleDB rejects it.""" - path = Path(__file__).resolve().parents[1] / "sql" / "migrations" / filename - assert Migration.load(path).in_transaction is False diff --git a/data/tests/test_schema_contract.py b/data/tests/test_schema_contract.py index ea3f164..99b19d9 100644 --- a/data/tests/test_schema_contract.py +++ b/data/tests/test_schema_contract.py @@ -1,6 +1,6 @@ """Contract tests binding the Drizzle read layer to the canonical SQL schema. -ADR 0006 makes the SQL migrations canonical and the Drizzle schema a typed +ADR 0006 makes the single SQL schema canonical and the Drizzle schema a typed mirror of them. Nothing enforced that, so the two drifted: enum values and table definitions were maintained independently in both places. @@ -17,41 +17,38 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[2] -MIGRATIONS_DIR = REPO_ROOT / "data" / "sql" / "migrations" +SCHEMA_PATH = REPO_ROOT / "data" / "sql" / "schema.sql" DRIZZLE_SCHEMAS = REPO_ROOT / "monorepo" / "packages" / "db" / "schemas" -def migration_sql() -> str: - """Return every migration concatenated, with original casing preserved. +def canonical_sql() -> str: + """Read the canonical schema with original casing preserved. Identifiers are compared case-insensitively via the patterns below; the text itself must not be lowercased or enum *values* would be corrupted along with the keywords. """ - return "\n".join( - path.read_text(encoding="utf-8") - for path in sorted(MIGRATIONS_DIR.glob("*.sql")) - ) + return SCHEMA_PATH.read_text(encoding="utf-8") def sql_tables() -> set[str]: - """Return every table name created by the migrations.""" + """Return every table name created by the schema.""" return { name.lower() for name in re.findall( - r"create table if not exists\s+([a-zA-Z_]+)", - migration_sql(), + r"create table(?: if not exists)?\s+([a-zA-Z_]+)", + canonical_sql(), re.IGNORECASE, ) } def sql_enums() -> dict[str, list[str]]: - """Return every enum type created by the migrations, with its values.""" + """Return every enum type created by the schema, with its values.""" enums: dict[str, list[str]] = {} for name, body in re.findall( r"create type\s+\"?([a-zA-Z_]+)\"?\s+as enum\s*\(([^)]*)\)", - migration_sql(), + canonical_sql(), re.IGNORECASE, ): enums[name.lower()] = re.findall(r"'([^']+)'", body) @@ -105,7 +102,7 @@ def test_drizzle_declares_no_unknown_enum(self) -> None: @pytest.mark.parametrize("enum_name", sorted(drizzle_enums())) def test_values_match_sql(self, enum_name: str) -> None: assert drizzle_enums()[enum_name] == sql_enums()[enum_name], ( - f"'{enum_name}' differs between the SQL migrations and " + f"'{enum_name}' differs between the SQL schema and " f"packages/db/schemas/enums.ts" ) @@ -146,7 +143,7 @@ def test_every_drizzle_view_exists_in_sql(self) -> None: name.lower() for name in re.findall( r"create (?:or replace )?(?:materialized )?view\s+([a-zA-Z_0-9]+)", - migration_sql(), + canonical_sql(), re.IGNORECASE, ) } diff --git a/data/tests/test_schema_database.py b/data/tests/test_schema_database.py new file mode 100644 index 0000000..3b50f3c --- /dev/null +++ b/data/tests/test_schema_database.py @@ -0,0 +1,451 @@ +"""Schema lifecycle tests confined to databases created and owned by this suite.""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from contextlib import closing +from dataclasses import replace +from io import StringIO +from uuid import uuid4 + +import psycopg2 +import psycopg2.extensions +import pytest +from psycopg2 import sql + +from schema import CONTRACT_PATH, SCHEMA_PATH, SEEDS_DIR +from schema_catalog import read_catalog +from schema_engine import accept_engine +from schema_install import ( + LEDGER_DDL, + SchemaContract, + adopt, + apply_seeds, + check_installed, + has_table, + install_fresh, + installer_lock, + verify_history, + verify_structure, +) +from schema_sql import SqlFile, discover + +pytestmark = pytest.mark.skipif( + os.getenv("SCHEMA_DATABASE_TEST") != "1", + reason="Set SCHEMA_DATABASE_TEST=1 to create disposable schema test databases", +) +SOURCE = SqlFile.load(SCHEMA_PATH) +CONTRACT = SchemaContract.load(CONTRACT_PATH, SOURCE) +SEEDS = discover(SEEDS_DIR) + + +def connect_test_database(name: str) -> psycopg2.extensions.connection: + """Use test server settings, with a database name owned by this suite.""" + return psycopg2.connect( + host=os.getenv("TEST_POSTGRES_HOST", "localhost"), + port=os.getenv("TEST_POSTGRES_PORT", "5432"), + user=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), + dbname=name, + ) + + +@pytest.fixture +def database() -> Iterator[psycopg2.extensions.connection]: + # The database name never comes from POSTGRES_DB or another external target. + name = "well_schema_test_" + uuid4().hex + with closing(connect_test_database("postgres")) as maintenance: + maintenance.autocommit = True + with maintenance.cursor() as cursor: + cursor.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name))) + try: + with ( + closing(connect_test_database(name)) as conn, + installer_lock(conn), + ): + yield conn + finally: + with maintenance.cursor() as cursor: + cursor.execute(sql.SQL("DROP DATABASE {}").format(sql.Identifier(name))) + + +@pytest.fixture +def installed( + database: psycopg2.extensions.connection, +) -> psycopg2.extensions.connection: + install_fresh(database, SOURCE, CONTRACT) + apply_seeds(database, SEEDS) + return database + + +@pytest.fixture +def legacy(installed: psycopg2.extensions.connection) -> psycopg2.extensions.connection: + # The restored historical database is also verified during release. Here + # the catalog is identical, so only the pre-adoption bookkeeping differs. + with installed.cursor() as cursor: + cursor.execute("DROP TABLE schema_installation") + cursor.executemany( + "INSERT INTO schema_migrations (filename, checksum) VALUES (%s,%s)", + list(CONTRACT.legacy_checksums.items()), + ) + return installed + + +def test_fresh_schema_matches_generated_reference( + installed: psycopg2.extensions.connection, +) -> None: + assert read_catalog(installed) == CONTRACT.catalog + check_installed(installed, CONTRACT, SEEDS) + assert apply_seeds(installed, SEEDS) == 0 + with installed.cursor() as cursor: + cursor.execute("SELECT count(*) FROM wellhead") + assert cursor.fetchone() == (12,) + + +def test_adoption_preserves_rows_objects_and_history( + legacy: psycopg2.extensions.connection, +) -> None: + with legacy.cursor() as cursor: + cursor.execute( + "UPDATE wellhead SET name='preserved-test-well' WHERE wellhead_id=1" + ) + cursor.execute("SELECT * FROM schema_migrations ORDER BY filename") + history = cursor.fetchall() + cursor.execute( + "SELECT 'users'::regclass::oid, 'parameterreading'::regclass::oid" + ) + identities = cursor.fetchone() + adopt(legacy, SOURCE, CONTRACT, SEEDS) + adopt(legacy, SOURCE, CONTRACT, SEEDS) + with legacy.cursor() as cursor: + cursor.execute("SELECT * FROM schema_migrations ORDER BY filename") + assert cursor.fetchall() == history + cursor.execute( + "SELECT 'users'::regclass::oid, 'parameterreading'::regclass::oid" + ) + assert cursor.fetchone() == identities + cursor.execute("SELECT name FROM wellhead WHERE wellhead_id=1") + assert cursor.fetchone() == ("preserved-test-well",) + check_installed(legacy, CONTRACT, SEEDS) + + +@pytest.mark.parametrize( + "mutation", + [ + "UPDATE schema_migrations SET checksum='wrong' " + "WHERE filename='0001_core_schema.sql'", + "DELETE FROM schema_migrations WHERE filename='0001_core_schema.sql'", + "INSERT INTO schema_migrations VALUES ('unknown.sql','unknown',now())", + "ALTER TABLE users ALTER COLUMN session_version SET DEFAULT 9", + ], +) +def test_bad_legacy_state_is_refused_without_adoption( + legacy: psycopg2.extensions.connection, + mutation: str, +) -> None: + with legacy.cursor() as cursor: + cursor.execute(mutation) + with pytest.raises(RuntimeError): + adopt(legacy, SOURCE, CONTRACT, SEEDS) + assert not has_table(legacy, "schema_installation") + + +@pytest.mark.parametrize( + ("mutation", "section"), + [ + ("ALTER TABLE users ALTER COLUMN session_version SET DEFAULT 9", "columns"), + ("ALTER TABLE users ADD CHECK (session_version>=0)", "constraints"), + ("DROP INDEX ix_control_inbox", "indexes"), + ( + "ALTER TABLE control_command DISABLE TRIGGER enforce_control_lifecycle", + "triggers", + ), + ("ALTER FUNCTION lock_control_state(integer) SECURITY INVOKER", "functions"), + ( + "GRANT EXECUTE ON FUNCTION lock_control_state(integer) TO PUBLIC", + "functions", + ), + ( + "ALTER DOMAIN model_version_text DROP CONSTRAINT model_version_text_check", + "types", + ), + ("ALTER TABLE users ENABLE ROW LEVEL SECURITY", "relations"), + ( + "SELECT set_chunk_time_interval('parameterreading', INTERVAL '2 days')", + "dimensions", + ), + ( + "SELECT alter_job(job_id, schedule_interval=>INTERVAL '9 days') " + "FROM timescaledb_information.jobs " + "WHERE hypertable_name='parameterreading'", + "jobs", + ), + ( + "ALTER TABLE parameterreading " + "SET (timescaledb.compress_orderby='timestamp_utc ASC')", + "compression", + ), + ( + "ALTER MATERIALIZED VIEW mv_2min_pressure_trends " + "SET (timescaledb.materialized_only=false)", + "aggregates", + ), + ], +) +def test_actual_drift_is_detected_despite_matching_source_checksum( + installed: psycopg2.extensions.connection, + mutation: str, + section: str, +) -> None: + with installed.cursor() as cursor: + cursor.execute(mutation) + with pytest.raises(RuntimeError, match=section): + check_installed(installed, CONTRACT, SEEDS) + + +def test_check_works_in_read_only_session( + installed: psycopg2.extensions.connection, +) -> None: + with installed.cursor() as cursor: + cursor.execute("SET default_transaction_read_only=on") + check_installed(installed, CONTRACT, SEEDS) + + +def test_legacy_invalidation_target_is_checked_when_supported( + installed: psycopg2.extensions.connection, +) -> None: + with installed.cursor() as cursor: + cursor.execute( + "SELECT to_regprocedure(" + "'_timescaledb_functions.continuous_agg_invalidation_trigger()')" + ) + supported = cursor.fetchone() != (None,) + if not supported: + # New TimescaleDB releases remove this internal trigger. The + # generated target contract must not keep requiring it. + assert not any( + "ts_cagg_invalidation_trigger" in row + for row in CONTRACT.catalog["triggers"] + ) + check_installed(installed, CONTRACT, SEEDS) + return + cursor.execute( + "DROP TRIGGER ts_cagg_invalidation_trigger ON parameterreading; " + "CREATE TRIGGER ts_cagg_invalidation_trigger AFTER INSERT OR DELETE " + "OR UPDATE ON parameterreading FOR EACH ROW EXECUTE FUNCTION " + "_timescaledb_functions.continuous_agg_invalidation_trigger('2')" + ) + with pytest.raises(RuntimeError, match="triggers"): + check_installed(installed, CONTRACT, SEEDS) + + +def test_concurrent_installer_has_bounded_wait( + database: psycopg2.extensions.connection, +) -> None: + with ( + closing(connect_test_database(database.info.dbname)) as other, + pytest.raises(RuntimeError, match="holds the database lock"), + installer_lock(other, timeout_seconds=0.02), + ): + pytest.fail("Second installer acquired the lock") + + +def test_interrupted_install_cannot_be_mistaken_for_ready( + database: psycopg2.extensions.connection, +) -> None: + broken = replace(SOURCE, sql=SOURCE.sql + "\nSELECT missing_schema_function();") + with pytest.raises(psycopg2.Error): + install_fresh(database, broken, CONTRACT) + assert has_table(database, "schema_installation") + with pytest.raises(RuntimeError, match="incomplete"): + check_installed(database, CONTRACT, SEEDS) + with pytest.raises(RuntimeError, match="not empty"): + install_fresh(database, SOURCE, CONTRACT) + + +def test_unknown_version_is_rejected_before_install( + database: psycopg2.extensions.connection, +) -> None: + wrong = replace(CONTRACT, versions={"postgres_major": "0", "timescaledb": "0"}) + with pytest.raises(RuntimeError, match="versions differ"): + install_fresh(database, SOURCE, wrong) + assert not has_table(database, "schema_installation") + + +def test_changed_baseline_requires_conversion( + installed: psycopg2.extensions.connection, +) -> None: + with pytest.raises(RuntimeError, match="reviewed conversion"): + check_installed(installed, replace(CONTRACT, source_checksum="changed"), SEEDS) + + +def test_empty_database_cannot_be_adopted( + database: psycopg2.extensions.connection, +) -> None: + with pytest.raises(RuntimeError, match="history is missing"): + adopt(database, SOURCE, CONTRACT, SEEDS) + assert not has_table(database, "schema_installation") + + +def test_history_alone_does_not_prove_structure( + database: psycopg2.extensions.connection, +) -> None: + with database.cursor() as cursor: + cursor.execute(LEDGER_DDL) + cursor.executemany( + "INSERT INTO schema_migrations (filename,checksum) VALUES (%s,%s)", + list(CONTRACT.legacy_checksums.items()), + ) + verify_history(database, CONTRACT, SEEDS, require_legacy=True) + with pytest.raises(RuntimeError, match="Schema differs"): + verify_structure(database, CONTRACT) + + +def test_generated_timescale_ids_do_not_change_the_contract( + database: psycopg2.extensions.connection, +) -> None: + with database.cursor() as cursor: + cursor.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") + cursor.execute("CREATE TABLE schema_id_probe (sampled_at TIMESTAMPTZ)") + cursor.execute("SELECT create_hypertable('schema_id_probe', 'sampled_at')") + cursor.execute("DROP TABLE schema_id_probe") + install_fresh(database, SOURCE, CONTRACT) + assert read_catalog(database) == CONTRACT.catalog + + +def test_failed_adoption_rolls_back_baseline_record( + legacy: psycopg2.extensions.connection, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_completion( + conn: psycopg2.extensions.connection, + checksum: str, + ) -> None: + del conn, checksum + raise RuntimeError("Injected completion failure") + + monkeypatch.setattr("schema_install._mark_ready", fail_completion) + with pytest.raises(RuntimeError, match="completion failure"): + adopt(legacy, SOURCE, CONTRACT, SEEDS) + assert not has_table(legacy, "schema_installation") + verify_history(legacy, CONTRACT, SEEDS, require_legacy=True) + + +@pytest.fixture +def previous_engine(installed: psycopg2.extensions.connection) -> SchemaContract: + # Exercise acceptance against the real target catalog. The release rehearsal + # separately uses an actual pg_upgrade and the saved pre-upgrade reference. + previous = replace( + CONTRACT, versions={"postgres_major": "14", "timescaledb": "2.19.2"} + ) + with installed.cursor() as cursor: + cursor.execute( + "UPDATE schema_installation SET contract_checksum=%s", + (previous.checksum,), + ) + return previous + + +def test_engine_acceptance_preserves_data_and_audits_once( + installed: psycopg2.extensions.connection, previous_engine: SchemaContract +) -> None: + with installed.cursor() as cursor: + cursor.execute("SELECT * FROM users ORDER BY id") + accounts = cursor.fetchall() + cursor.execute("SELECT * FROM schema_migrations ORDER BY filename") + history = cursor.fetchall() + accept_engine(installed, previous_engine, CONTRACT, SEEDS) + accept_engine(installed, previous_engine, CONTRACT, SEEDS) + check_installed(installed, CONTRACT, SEEDS) + with installed.cursor() as cursor: + cursor.execute("SELECT * FROM users ORDER BY id") + assert cursor.fetchall() == accounts + cursor.execute("SELECT * FROM schema_migrations ORDER BY filename") + assert cursor.fetchall() == history + cursor.execute( + "SELECT actor_identifier=current_user, detail FROM auditevent " + "WHERE action='schema.engine.accepted'" + ) + events = cursor.fetchall() + assert len(events) == 1 + assert events[0][0] is True + assert events[0][1]["previous_contract"] == previous_engine.checksum + assert events[0][1]["target_versions"] == CONTRACT.versions + + +@pytest.mark.parametrize( + "mutation", + [ + "ALTER TABLE users ALTER COLUMN session_version SET DEFAULT 9", + "UPDATE schema_installation SET contract_checksum='unexpected'", + "UPDATE schema_installation SET status='installing'", + "UPDATE schema_migrations SET checksum='changed'", + ], +) +def test_engine_acceptance_rejects_drift_without_changing_identity( + installed: psycopg2.extensions.connection, + previous_engine: SchemaContract, + mutation: str, +) -> None: + with installed.cursor() as cursor: + cursor.execute(mutation) + cursor.execute("SELECT * FROM schema_installation") + baseline = cursor.fetchall() + with pytest.raises(RuntimeError): + accept_engine(installed, previous_engine, CONTRACT, SEEDS) + with installed.cursor() as cursor: + cursor.execute("SELECT * FROM schema_installation") + assert cursor.fetchall() == baseline + cursor.execute( + "SELECT count(*) FROM auditevent WHERE action='schema.engine.accepted'" + ) + assert cursor.fetchone() == (0,) + + +def test_engine_acceptance_rolls_back_if_audit_write_fails( + installed: psycopg2.extensions.connection, previous_engine: SchemaContract +) -> None: + # Exhausting this sequence leaves the catalog definition unchanged but makes + # the actual audit insert fail, exercising the transaction's rollback. + with installed.cursor() as cursor: + cursor.execute( + "SELECT setval('auditevent_audit_event_id_seq', 9223372036854775807)" + ) + with pytest.raises(psycopg2.Error): + accept_engine(installed, previous_engine, CONTRACT, SEEDS) + with installed.cursor() as cursor: + cursor.execute("SELECT contract_checksum FROM schema_installation") + assert cursor.fetchone() == (previous_engine.checksum,) + + +@pytest.mark.parametrize("changed", ["source", "history", "versions"]) +def test_engine_acceptance_rejects_unrelated_reference_changes( + installed: psycopg2.extensions.connection, + previous_engine: SchemaContract, + changed: str, +) -> None: + candidate = { + "source": replace(previous_engine, source_checksum="different"), + "history": replace(previous_engine, legacy_checksums={}), + "versions": replace(previous_engine, versions=CONTRACT.versions), + }[changed] + with pytest.raises(RuntimeError): + accept_engine(installed, candidate, CONTRACT, SEEDS) + + +def test_compressed_null_backup_value_can_be_restored( + database: psycopg2.extensions.connection, +) -> None: + # TimescaleDB 2.19.3 emits this valid all-null compressed batch in pg_dump, + # then fails to decode it during COPY. This is the observed restore defect + # in upstream issue 8893, independently reproduced during the upgrade. + with database.cursor() as cursor: + cursor.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") + cursor.execute( + "CREATE TEMP TABLE null_batch_probe " + "(value _timescaledb_internal.compressed_data)" + ) + cursor.copy_expert("COPY null_batch_probe FROM STDIN", StringIO("Bg==\n")) + cursor.execute("SELECT value::text FROM null_batch_probe") + assert cursor.fetchall() == [("Bg==",)] diff --git a/data/tests/test_schema_extend.py b/data/tests/test_schema_extend.py new file mode 100644 index 0000000..d84de91 --- /dev/null +++ b/data/tests/test_schema_extend.py @@ -0,0 +1,73 @@ +"""Reject unsafe extension shapes before opening a write transaction.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from schema import CONTRACT_PATH +from schema_extend import extension_statements +from schema_install import SchemaContract +from schema_sql import SqlFile + + +def source(text: str) -> SqlFile: + return SqlFile(Path("schema.sql"), text, hashlib.sha256(text.encode()).hexdigest()) + + +def previous_contract() -> SchemaContract: + # This fixture only exercises declaration validation. The full target catalog + # is generated and checked by the separate database extension tests. + raw = json.loads(CONTRACT_PATH.read_text()) + return SchemaContract( + raw["source_checksum"], raw["versions"], raw["legacy_checksums"], raw["catalog"] + ) + + +def test_new_tables_and_their_indexes_accept_quoted_semicolons() -> None: + old = source("-- existing\nSELECT 1;\n") + target = source( + old.sql + + ( + "/* new */ CREATE TABLE new_work (id UUID PRIMARY KEY, reason " + "TEXT DEFAULT ';'); CREATE INDEX ix_new_work ON new_work(reason)" + ) + ) + statements, tables = extension_statements(old, target, previous_contract()) + assert tables == ["new_work"] + assert len(statements) == 2 + assert "DEFAULT ';'" in statements[0] + + +@pytest.mark.parametrize( + "suffix", + [ + "DELETE FROM users", + "ALTER TABLE users ADD COLUMN danger TEXT", + "DROP TABLE users", + "CREATE TABLE users (id INT)", + "CREATE TABLE new_work AS SELECT * FROM users", + "CREATE TABLE new_work (id INT) INHERITS (users)", + "CREATE TABLE new_work (id INT); CREATE INDEX ix_old ON users(id)", + "CREATE TABLE new_work (id INT); CREATE INDEX CONCURRENTLY ix_new ON " + "new_work(id)", + "CREATE TABLE new_work (id INT); CREATE TABLE new_work (other INT)", + "CREATE FUNCTION changed() RETURNS INT LANGUAGE SQL AS $$ SELECT 1 $$", + ], +) +def test_unsupported_or_existing_targets_are_refused(suffix: str) -> None: + old = source("SELECT 1;\n") + with pytest.raises(RuntimeError): + extension_statements(old, source(old.sql + suffix), previous_contract()) + + +def test_changed_source_prefix_is_refused() -> None: + with pytest.raises(RuntimeError, match="exact previous"): + extension_statements( + source("SELECT 1;"), + source("SELECT 2; CREATE TABLE new_work(id INT)"), + previous_contract(), + ) diff --git a/data/tests/test_schema_extension_database.py b/data/tests/test_schema_extension_database.py new file mode 100644 index 0000000..979a194 --- /dev/null +++ b/data/tests/test_schema_extension_database.py @@ -0,0 +1,139 @@ +"""Prove additive schema installation preserves data and rolls back on failure.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Generator +from contextlib import closing +from dataclasses import replace +from pathlib import Path +from uuid import uuid4 + +import psycopg2 +import pytest +from psycopg2 import sql +from test_schema_database import ( + CONTRACT, + SEEDS, + SOURCE, + connect_test_database, + database, + installed, + pytestmark, +) + +from schema_catalog import read_catalog +from schema_extend import extend_schema +from schema_install import ( + SchemaContract, + check_installed, + install_fresh, + installer_lock, +) +from schema_sql import SqlFile + +# Reuse the opt-in, suite-owned database fixtures, never an application database. +__all__ = ["database", "installed", "pytestmark"] + + +@pytest.fixture +def extension() -> Generator[tuple[SqlFile, SchemaContract]]: + text = SOURCE.sql + ( + "\nCREATE TABLE extension_probe (id INT PRIMARY KEY, value " + "TEXT);\nCREATE INDEX ix_extension_probe ON extension_probe(value);\n" + ) + target = SqlFile( + Path("schema.sql"), text, hashlib.sha256(text.encode()).hexdigest() + ) + name = "well_extension_reference_" + uuid4().hex + with closing(connect_test_database("postgres")) as admin: + admin.autocommit = True + with admin.cursor() as cur: + cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name))) + try: + with closing(connect_test_database(name)) as conn, installer_lock(conn): + install_fresh(conn, target, None) + contract = replace( + CONTRACT, + source_checksum=target.checksum, + catalog=read_catalog(conn), + ) + yield target, contract + finally: + with admin.cursor() as cur: + cur.execute(sql.SQL("DROP DATABASE {}").format(sql.Identifier(name))) + + +def test_extension_preserves_rows_objects_history_and_is_repeatable( + installed: psycopg2.extensions.connection, + extension: tuple[SqlFile, SchemaContract], +) -> None: + target, contract = extension + with installed.cursor() as cur: + cur.execute("UPDATE wellhead SET name='retained-well' WHERE wellhead_id=1") + cur.execute("SELECT 'users'::regclass::oid, 'parameterreading'::regclass::oid") + identities = cur.fetchone() + cur.execute("SELECT * FROM schema_migrations ORDER BY filename") + history = cur.fetchall() + extend_schema(installed, SOURCE, target, CONTRACT, contract, SEEDS) + extend_schema(installed, SOURCE, target, CONTRACT, contract, SEEDS) + check_installed(installed, contract, SEEDS) + with installed.cursor() as cur: + cur.execute("SELECT name FROM wellhead WHERE wellhead_id=1") + assert cur.fetchone() == ("retained-well",) + cur.execute("SELECT 'users'::regclass::oid, 'parameterreading'::regclass::oid") + assert cur.fetchone() == identities + cur.execute("SELECT * FROM schema_migrations ORDER BY filename") + assert cur.fetchall() == history + cur.execute("SELECT count(*) FROM auditevent WHERE action='schema.extended'") + assert cur.fetchone() == (1,) + + +def test_extension_catalog_mismatch_rolls_back_every_addition( + installed: psycopg2.extensions.connection, + extension: tuple[SqlFile, SchemaContract], +) -> None: + target, contract = extension + bad = { + **contract.catalog, + "columns": [ + row + for row in contract.catalog["columns"] + if '"extension_probe", "value"' not in row + ], + } + with pytest.raises(RuntimeError, match="Schema differs"): + extend_schema( + installed, SOURCE, target, CONTRACT, replace(contract, catalog=bad), SEEDS + ) + check_installed(installed, CONTRACT, SEEDS) + with installed.cursor() as cur: + cur.execute("SELECT to_regclass('extension_probe')") + assert cur.fetchone() == (None,) + + +def test_extension_audit_failure_rolls_back_identity_and_tables( + installed: psycopg2.extensions.connection, + extension: tuple[SqlFile, SchemaContract], + monkeypatch: pytest.MonkeyPatch, +) -> None: + target, contract = extension + from schema_install import verify_structure + + def fail_audit( + conn: psycopg2.extensions.connection, reference: SchemaContract + ) -> None: + verify_structure(conn, reference) + with conn.cursor() as cur: + cur.execute( + "ALTER TABLE auditevent ADD CONSTRAINT test_audit_failure " + "CHECK (action <> 'schema.extended')" + ) + + monkeypatch.setattr("schema_extend.verify_structure", fail_audit) + with pytest.raises(psycopg2.errors.CheckViolation): + extend_schema(installed, SOURCE, target, CONTRACT, contract, SEEDS) + check_installed(installed, CONTRACT, SEEDS) + with installed.cursor() as cur: + cur.execute("SELECT to_regclass('extension_probe')") + assert cur.fetchone() == (None,) diff --git a/data/tests/test_schema_install.py b/data/tests/test_schema_install.py new file mode 100644 index 0000000..e2f5740 --- /dev/null +++ b/data/tests/test_schema_install.py @@ -0,0 +1,59 @@ +"""Reference integrity and parser failures that do not require a database.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from schema import CONTRACT_PATH, SCHEMA_PATH +from schema_install import SchemaContract +from schema_sql import SqlFile, split_statements + + +def test_reference_is_bound_to_current_source() -> None: + contract = SchemaContract.load(CONTRACT_PATH, SqlFile.load(SCHEMA_PATH)) + assert len(contract.legacy_checksums) == 13 + assert len(contract.catalog["hypertables"]) == 7 + assert len(contract.catalog["aggregates"]) == 4 + + +def test_stale_reference_is_rejected(tmp_path: Path) -> None: + changed = tmp_path / "schema.sql" + changed.write_text(SCHEMA_PATH.read_text() + "\n-- changed\n") + with pytest.raises(RuntimeError, match="rebuild and verify"): + SchemaContract.load(CONTRACT_PATH, SqlFile.load(changed)) + + +def test_missing_comparison_section_is_rejected(tmp_path: Path) -> None: + raw = json.loads(CONTRACT_PATH.read_text()) + del raw["catalog"]["jobs"] + changed = tmp_path / "contract.json" + changed.write_text(json.dumps(raw)) + with pytest.raises(ValueError, match="comparison sections"): + SchemaContract.load(changed, SqlFile.load(SCHEMA_PATH)) + + +@pytest.mark.parametrize( + "body", + [ + """SELECT "semi;colon"; SELECT 2;""", + "SELECT 'can''t; split'; SELECT 2;", + "SELECT E'can\\'t; split'; SELECT 2;", + "/* outer /* inner ; */ still ; comment */ SELECT 1; SELECT 2;", + ], +) +def test_quoted_separators_and_nested_comments(body: str) -> None: + statements = split_statements(body) + assert len(statements) == 2 + assert statements[-1] == "SELECT 2" + + +@pytest.mark.parametrize( + "body", + ["SELECT 'unfinished", 'SELECT "unfinished', "DO $$ unfinished", "/* unfinished"], +) +def test_incomplete_source_is_rejected_before_install(body: str) -> None: + with pytest.raises(ValueError, match="Unterminated SQL"): + split_statements(body) diff --git a/data/tests/test_schema_normalize.py b/data/tests/test_schema_normalize.py new file mode 100644 index 0000000..b7ffa9c --- /dev/null +++ b/data/tests/test_schema_normalize.py @@ -0,0 +1,91 @@ +"""Limit engine comparison equivalence to the verified literal-array casts.""" + +from __future__ import annotations + +import json + +import pytest + +from schema_catalog import differences +from schema_normalize import normalize_text_arrays + + +@pytest.mark.parametrize("literal", ["'success'", "'can''t'", "'::character varying'"]) +def test_whole_array_and_element_casts_compare_equal(literal: str) -> None: + whole = f"ANY ((ARRAY[{literal}::character varying])::text[])" + elements = f"ANY (ARRAY[({literal}::character varying)::text])" + assert normalize_text_arrays(whole) == normalize_text_arrays(elements) + assert normalize_text_arrays(whole) == f"ANY (ARRAY[{literal}::text])" + + +@pytest.mark.parametrize( + "expression", + [ + "ARRAY[('a'::character varying(2))::text]", + "ARRAY[('a'::character)::text]", + "ARRAY[(some_column::character varying)::text]", + "ARRAY[('a'::character varying)::integer]", + "ARRAY[(NULL::character varying)::text]", + "'ARRAY[(''a''::character varying)::text]'", + "\"ARRAY[('a'::character varying)::text]\"", + "$$ARRAY[('a'::character varying)::text]$$", + "/* ARRAY[('a'::character varying)::text] */ SELECT 1", + "-- ARRAY[('a'::character varying)::text]\nSELECT 1", + ], +) +def test_other_casts_and_quoted_sql_remain_unchanged(expression: str) -> None: + assert normalize_text_arrays(expression) == expression + + +def test_changed_values_still_compare_differently() -> None: + original = "ANY ((ARRAY['a'::character varying, 'b'::character varying])::text[])" + for changed in ("'x'", "'b'"): + candidate = original.replace("'a'", changed) + assert normalize_text_arrays(original) != normalize_text_arrays(candidate) + + +def test_duplicate_catalog_entries_are_not_hidden_by_comparison() -> None: + row = json.dumps(["parameterreading", "policy_compression"]) + assert differences({"jobs": [row]}, {"jobs": [row, row]}) + + +@pytest.mark.parametrize("column", ["interval_seconds", "horizon_seconds"]) +def test_bounded_minutes_restore_preserves_all_comparisons(column: str) -> None: + from schema_normalize import normalize_bounded_minutes + + fresh = ( + f"CHECK (((({column} >= 300) AND ({column} <= 1800)) " + f"AND (({column} % 60) = 0)))" + ) + restored = ( + f"CHECK ((({column} >= 300) AND ({column} <= 1800) AND (({column} % 60) = 0)))" + ) + assert normalize_bounded_minutes(fresh) == restored + assert normalize_bounded_minutes(restored) == restored + + def row(definition: str, valid: bool = True) -> str: + return json.dumps(["forecast_schedule", "bounds", definition, valid]) + + expected = {"constraints": [row(fresh)]} + assert not differences(expected, {"constraints": [row(restored)]}) + assert differences(expected, {"constraints": [row(restored, False)]}) + for before, after in [ + ("300", "301"), + ("1800", "1860"), + ("60", "30"), + ("= 0", "= 1"), + (">=", ">"), + ("AND", "OR"), + (column, "another_column"), + ]: + assert differences( + expected, {"constraints": [row(restored.replace(before, after))]} + ) + for other in [ + "'" + fresh + "'", + "/* " + fresh + " */", + fresh + " NOT VALID", + fresh.replace("AND", "OR"), + fresh.replace("300", "some_function()"), + ]: + assert normalize_bounded_minutes(other) == other diff --git a/data/tests/test_schema_normalize_database.py b/data/tests/test_schema_normalize_database.py new file mode 100644 index 0000000..fa1561c --- /dev/null +++ b/data/tests/test_schema_normalize_database.py @@ -0,0 +1,47 @@ +"""Prove the dump/restore parser equivalence on the pinned PostgreSQL engine.""" + +from __future__ import annotations + +import psycopg2.extensions +from psycopg2 import sql +from test_schema_database import database, pytestmark + +from schema_normalize import normalize_bounded_minutes + +__all__ = ["database", "pytestmark"] + + +def test_dumped_duration_check_compares_equal_after_reparse( + database: psycopg2.extensions.connection, +) -> None: + with database.cursor() as cursor: + cursor.execute( + "CREATE TABLE duration_probe (interval_seconds INT CHECK " + "(interval_seconds BETWEEN 300 AND 86400 AND interval_seconds % 60 = 0))" + ) + cursor.execute( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conrelid='duration_probe'::regclass" + ) + original = cursor.fetchone()[0] + # The definition comes from this test's own table, as it would in pg_dump. + cursor.execute( + sql.SQL("CREATE TABLE restored_probe (interval_seconds INT {})").format( + sql.SQL(original) + ) + ) + cursor.execute( + "SELECT pg_get_constraintdef(oid) FROM pg_constraint " + "WHERE conrelid='restored_probe'::regclass" + ) + restored = cursor.fetchone()[0] + assert original != restored + assert normalize_bounded_minutes(original) == restored + cursor.execute( + "SELECT bool_and(((a AND b) AND c) IS NOT DISTINCT FROM (a AND b AND c)) " + "FROM (VALUES (true),(false),(NULL::boolean)) x(a) " + "CROSS JOIN (VALUES (true),(false),(NULL::boolean)) y(b) " + "CROSS JOIN (VALUES (true),(false),(NULL::boolean)) z(c)" + ) + assert cursor.fetchone()[0] is True + database.rollback() diff --git a/data/tests/test_service_metrics.py b/data/tests/test_service_metrics.py new file mode 100644 index 0000000..380a950 --- /dev/null +++ b/data/tests/test_service_metrics.py @@ -0,0 +1,82 @@ +"""Check that service metrics report current health and confirmed commits.""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone +from unittest.mock import Mock +from urllib.request import urlopen + +import pytest + +import database_ingestion as ingestion +from telemetry_common import ( + DatabaseSettings, + ModelVersion, + ServiceHealth, + start_service_endpoints, +) + + +def test_telemetry_metric_tracks_health_without_a_readiness_request() -> None: + health = ServiceHealth() + server = start_service_endpoints("test", health, 0) + try: + for alive, ready, expected in [ + (True, False, 0), + (True, True, 1), + (False, True, 0), + ]: + health.alive, health.ready = alive, ready + with urlopen( + f"http://127.0.0.1:{server.server_port}/metrics", timeout=2 + ) as response: + assert ( + f"well_sim_telemetry_ready {expected}.0" in response.read().decode() + ) + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize("commit_succeeds", [True, False]) +def test_timestamp_changes_only_after_a_commit( + monkeypatch: pytest.MonkeyPatch, commit_succeeds: bool +) -> None: + connection = Mock(closed=False) + if not commit_succeeds: + connection.commit.side_effect = RuntimeError("commit failed") + metric = Mock() + monkeypatch.setattr(ingestion, "connect_db", Mock(return_value=connection)) + monkeypatch.setattr(ingestion, "LAST_COMMIT_TIMESTAMP", metric) + monkeypatch.setattr( + ingestion, "apply_metadata_reload", lambda _loader, mappings: mappings + ) + monkeypatch.setattr(ingestion, "execute_batch", Mock()) + now = datetime.now(timezone.utc) + status = ingestion.GatewayStatus(int(time.time()), ModelVersion(0, 3, 0)) + monkeypatch.setattr( + ingestion, "read_fleet_status", Mock(side_effect=[status, RuntimeError("stop")]) + ) + reading = ingestion.Reading(now, 1, 1, 1, 1.0, status.model_version) + monkeypatch.setattr( + ingestion, "poll_consistent_batch", Mock(return_value=(status, [reading])) + ) + monkeypatch.setattr(time, "sleep", Mock()) + settings = ingestion.Settings( + "unused", + 5020, + 5, + DatabaseSettings("unused", 5432, "unused", "unused", "unused"), + ) + health = ServiceHealth() + with pytest.raises(RuntimeError, match=r"stop|commit failed"): + ingestion.ingest_forever(settings, [Mock()], Mock(), health) + if commit_succeeds: + metric.set.assert_called_once() + assert metric.set.call_args.args[0] >= now.timestamp() + assert health.ready + else: + metric.set.assert_not_called() + assert not health.ready + connection.close.assert_called_once() diff --git a/data/tests/test_telemetry.py b/data/tests/test_telemetry.py index efd56df..9378ddd 100644 --- a/data/tests/test_telemetry.py +++ b/data/tests/test_telemetry.py @@ -8,7 +8,11 @@ import json import logging +import time +from collections.abc import Callable from datetime import datetime, timezone +from types import SimpleNamespace +from typing import ClassVar from unittest.mock import Mock import pytest @@ -18,13 +22,18 @@ import modbus_gateway as gateway from telemetry_common import ( HEARTBEAT_REGISTER, + MODEL_VERSION_REGISTER, + STATUS_BLOCK_REGISTERS, WORD_ORDER, JsonLogFormatter, + ModelVersion, ParameterMapping, as_number, collect_unit_ids, ) +VERSION = ModelVersion(0, 3, 0) + def mapping( *, @@ -48,6 +57,21 @@ def mapping( ) +def publish(store: gateway.RegisterStore, telemetry: list[dict[str, object]]) -> None: + """Apply a batch the way the gateway does, stamped now with VERSION.""" + store.apply_batch(telemetry, datetime.now(timezone.utc), VERSION) + + +def status_registers(tick: int, version: list[int]) -> list[int]: + """Build the five-register status block: heartbeat words, then version.""" + return ( + ModbusClientMixin.convert_to_registers( + tick, ModbusClientMixin.DATATYPE.UINT32, word_order=WORD_ORDER + ) + + version + ) + + class TestRegisterCodec: """The gateway encodes and ingestion decodes; the two must agree exactly.""" @@ -124,9 +148,7 @@ class TestRegisterStore: def test_writes_value_to_mapped_unit_and_register(self) -> None: store = gateway.RegisterStore([mapping(modbus_register=10, modbus_unit_id=1)]) - store.apply_batch( - [{"wellhead_id": 1, "parameters": {"tubing_pressure": 1234.56}}] - ) + publish(store, [{"wellhead_id": 1, "parameters": {"tubing_pressure": 1234.56}}]) raw = store.context[1].getValues(3, 10, 2) assert ingestion.decode_registers(raw, "float") == pytest.approx( @@ -140,11 +162,12 @@ def test_units_do_not_share_an_address_space(self) -> None: mapping(wellhead_id=2, modbus_register=0, modbus_unit_id=7), ] ) - store.apply_batch( + publish( + store, [ {"wellhead_id": 1, "parameters": {"tubing_pressure": 111.0}}, {"wellhead_id": 2, "parameters": {"tubing_pressure": 222.0}}, - ] + ], ) assert ingestion.decode_registers( @@ -162,7 +185,7 @@ def test_heartbeat_written_for_every_unit(self) -> None: ] ) before = datetime.now(timezone.utc).timestamp() - store.apply_batch([{"wellhead_id": 1, "parameters": {}}]) + publish(store, [{"wellhead_id": 1, "parameters": {}}]) for unit_id in (1, 7): raw = store.context[unit_id].getValues(3, HEARTBEAT_REGISTER, 2) @@ -173,21 +196,56 @@ def test_heartbeat_written_for_every_unit(self) -> None: "heartbeat", ) assert written >= before - 1 + assert store.context[unit_id].getValues(3, MODEL_VERSION_REGISTER, 3) == [ + 0, + 3, + 0, + ] + + def test_clear_unpublishes_heartbeat_and_version(self) -> None: + """Mid-update, the whole status block reads zero, not only the tick.""" + store = gateway.RegisterStore([mapping()]) + publish(store, [{"wellhead_id": 1, "parameters": {}}]) + store.clear_heartbeat() + assert ( + store.context[1].getValues(3, HEARTBEAT_REGISTER, STATUS_BLOCK_REGISTERS) + == [0] * STATUS_BLOCK_REGISTERS + ) + + def test_status_block_round_trips_from_gateway_to_ingestion(self) -> None: + """What the gateway writes is exactly what ingestion reads back.""" + store = gateway.RegisterStore([mapping()]) + at = datetime(2026, 10, 2, 12, 0, tzinfo=timezone.utc) + store.apply_batch([{"wellhead_id": 1, "parameters": {}}], at, VERSION) + + def read(address: int, *, count: int, device_id: int) -> Mock: + result = Mock( + registers=store.context[device_id].getValues(3, address, count) + ) + result.isError.return_value = False + return result + + client = Mock() + client.read_holding_registers.side_effect = read + assert ingestion.read_status(client, 1) == ingestion.GatewayStatus( + int(at.timestamp()), VERSION + ) def test_unknown_wellhead_is_skipped_not_fatal(self) -> None: store = gateway.RegisterStore([mapping(wellhead_id=1)]) - store.apply_batch([{"wellhead_id": 99, "parameters": {"tubing_pressure": 1.0}}]) + publish(store, [{"wellhead_id": 99, "parameters": {"tubing_pressure": 1.0}}]) def test_malformed_batch_is_rejected(self) -> None: store = gateway.RegisterStore([mapping()]) with pytest.raises(TypeError, match="Malformed telemetry batch"): - store.apply_batch([{"wellhead_id": "one", "parameters": {}}]) + publish(store, [{"wellhead_id": "one", "parameters": {}}]) def test_heartbeat_clear_of_mapped_registers(self) -> None: """12 wellheads x 18 parameters tops out at register 1134.""" highest_mapped = (12 - 1) * 100 + (18 - 1) * 2 assert highest_mapped < HEARTBEAT_REGISTER - assert HEARTBEAT_REGISTER + 1 < gateway.REGISTER_BLOCK_SIZE + # The whole status block must fit inside the writable register range. + assert HEARTBEAT_REGISTER + STATUS_BLOCK_REGISTERS <= gateway.USABLE_REGISTERS class TestUnitIds: @@ -201,22 +259,30 @@ def test_collects_sorted_and_deduplicated(self) -> None: @pytest.mark.parametrize( - ("second_tick", "accepted"), - [(1_800_000_000, True), (1_800_000_001, False), (0, False)], + ("second_tick", "second_version", "accepted"), + [ + (1_800_000_000, [0, 3, 0], True), + (1_800_000_001, [0, 3, 0], False), + (0, [0, 3, 0], False), + (1_800_000_000, [0, 4, 0], False), + (1_800_000_000, [0, 0, 0], False), + ], ) -def test_fleet_heartbeat_requires_every_unit_on_one_tick( - second_tick: int, accepted: bool +def test_fleet_status_requires_every_unit_on_one_tick_and_version( + second_tick: int, second_version: list[int], accepted: bool ) -> None: - """A mixed or unready Modbus unit must not create a fleet batch.""" + """A mixed, unready, or unversioned Modbus unit must not create a batch.""" first_tick = 1_800_000_000 client = Mock() def read_result(address: int, *, count: int, device_id: int) -> Mock: + # One request per unit covers the heartbeat and the version. assert address == HEARTBEAT_REGISTER - assert count == 2 - tick = first_tick if device_id == 1 else second_tick - registers = ModbusClientMixin.convert_to_registers( - tick, ModbusClientMixin.DATATYPE.UINT32, word_order=WORD_ORDER + assert count == STATUS_BLOCK_REGISTERS + registers = ( + status_registers(first_tick, [0, 3, 0]) + if device_id == 1 + else status_registers(second_tick, second_version) ) result = Mock(registers=registers) result.isError.return_value = False @@ -227,8 +293,9 @@ def read_result(address: int, *, count: int, device_id: int) -> Mock: mapping(modbus_unit_id=1), mapping(mapping_id=2, wellhead_id=2, modbus_unit_id=7), ] - heartbeat = ingestion.read_fleet_heartbeat(client, mappings) - assert (heartbeat == first_tick) is accepted + status = ingestion.read_fleet_status(client, mappings) + expected = ingestion.GatewayStatus(first_tick, VERSION) + assert (status == expected) is accepted assert client.read_holding_registers.call_count == 2 @@ -243,8 +310,17 @@ def test_row_order_matches_insert_columns(self) -> None: parameter_type_id=4, mapping_id=5, raw_value=6.5, + model_version=VERSION, + ) + assert reading.as_row() == ( + now, + 3, + 4, + 5, + 6.5, + "synthetic_reduced_order_model", + "0.3.0", ) - assert reading.as_row() == (now, 3, 4, 5, 6.5, "synthetic_reduced_order_model") columns = ( ingestion.INSERT_READING_SQL.split("(", 1)[1].split(")", 1)[0].split(",") @@ -256,6 +332,7 @@ def test_row_order_matches_insert_columns(self) -> None: "mapping_id", "raw_value", "source_kind", + "model_version", ] @@ -303,7 +380,7 @@ def test_invalid_modbus_value_rejects_the_entire_poll() -> None: ) client.read_holding_registers.return_value.isError.return_value = False with pytest.raises(ValueError, match="Nonfinite"): - ingestion.poll_once(client, [mapping()], datetime.now(timezone.utc)) + ingestion.poll_once(client, [mapping()], datetime.now(timezone.utc), VERSION) def test_model_heartbeat_age_is_bounded() -> None: @@ -312,3 +389,186 @@ def test_model_heartbeat_age_is_bounded() -> None: assert ingestion.current_heartbeat(epoch - 10, now, 5) == epoch - 10 assert ingestion.current_heartbeat(epoch - 11, now, 5) is None assert ingestion.current_heartbeat(epoch + 3, now, 5) is None + + +class TestModelVersion: + """One definition of the version's text and register forms.""" + + def test_text_and_registers_round_trip(self) -> None: + version = ModelVersion.parse("12.0.65535") + assert version.to_registers() == [12, 0, 65535] + assert ModelVersion.from_registers(version.to_registers()) == version + assert str(version) == "12.0.65535" + + def test_zero_block_means_unpublished(self) -> None: + assert ModelVersion.from_registers([0, 0, 0]) is None + + @pytest.mark.parametrize( + "text", ["", "0.3", "0.3.0.1", "v0.3.0", "0.3.x", "0.3.70000", "0.0.0", "٣.1.1"] + ) + def test_rejects_text_that_cannot_be_three_registers(self, text: str) -> None: + with pytest.raises(ValueError, match="Model version"): + ModelVersion.parse(text) + + def test_rejects_non_text(self) -> None: + with pytest.raises(ValueError, match="must be text"): + ModelVersion.parse(3) + + +class TestPollRetry: + """A gateway update during a poll is retried, never stored as a mixed batch.""" + + MAPPINGS: ClassVar[list[ParameterMapping]] = [ + mapping(mapping_id=1, parameter_code="tubing_pressure", modbus_register=0), + mapping( + mapping_id=2, + parameter_type_id=2, + parameter_code="flow_rate", + modbus_register=2, + ), + ] + + def _publish( + self, store: gateway.RegisterStore, offset_s: int, value: float + ) -> None: + """Publish one tick `offset_s` seconds from now with both values set.""" + at = datetime.fromtimestamp( + int(datetime.now(timezone.utc).timestamp()) + offset_s, timezone.utc + ) + store.apply_batch( + [ + { + "wellhead_id": 1, + "parameters": {"tubing_pressure": value, "flow_rate": value}, + } + ], + at, + VERSION, + ) + + def _client( + self, + store: gateway.RegisterStore, + on_value_read: Callable[[], None] | None = None, + ) -> Mock: + """Build a Modbus client that reads the real register image. + + `on_value_read` runs after each value register is read, which is where + a test makes the gateway publish mid-poll. + """ + + def read(address: int, *, count: int, device_id: int) -> Mock: + result = Mock( + registers=store.context[device_id].getValues(3, address, count) + ) + result.isError.return_value = False + if address != HEARTBEAT_REGISTER and on_value_read is not None: + on_value_read() + return result + + client = Mock() + client.read_holding_registers.side_effect = read + return client + + def _start(self, client: Mock) -> ingestion.GatewayStatus: + status = ingestion.read_fleet_status(client, self.MAPPINGS) + assert status is not None + return status + + def test_clean_poll_needs_no_retry(self) -> None: + store = gateway.RegisterStore(self.MAPPINGS) + self._publish(store, -1, 100.0) + client = self._client(store) + status = self._start(client) + + result = ingestion.poll_consistent_batch( + client, self.MAPPINGS, status, time.monotonic() + 2.5, 5 + ) + + assert result is not None + polled, readings = result + assert polled == status + assert [r.raw_value for r in readings] == [100.0, 100.0] + + def test_clean_poll_finishing_after_deadline_is_discarded( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A coherent but late batch must not consume the next poll interval.""" + store = gateway.RegisterStore(self.MAPPINGS) + self._publish(store, -1, 100.0) + elapsed = [100.0] + value_reads = [0] + + def slow_read() -> None: + value_reads[0] += 1 + elapsed[0] = 101.0 + + client = self._client(store, slow_read) + status = self._start(client) + monkeypatch.setattr( + ingestion, "time", SimpleNamespace(monotonic=lambda: elapsed[0]) + ) + + result = ingestion.poll_consistent_batch( + client, self.MAPPINGS, status, deadline=100.5, interval_seconds=5 + ) + + assert value_reads[0] == len(self.MAPPINGS) + assert result is None + + def test_update_mid_poll_is_retried_on_the_new_tick(self) -> None: + store = gateway.RegisterStore(self.MAPPINGS) + self._publish(store, -1, 100.0) + published: list[bool] = [] + + def publish_once() -> None: + if not published: + published.append(True) + self._publish(store, 0, 200.0) + + client = self._client(store, publish_once) + status = self._start(client) + + result = ingestion.poll_consistent_batch( + client, self.MAPPINGS, status, time.monotonic() + 2.5, 5 + ) + + assert result is not None + polled, readings = result + assert polled.heartbeat == status.heartbeat + 1 + # Every reading comes from the new tick: no mix of 100 and 200. + assert [r.raw_value for r in readings] == [200.0, 200.0] + assert {r.timestamp_utc for r in readings} == { + datetime.fromtimestamp(polled.heartbeat, timezone.utc) + } + + def test_gives_up_at_the_deadline_when_every_poll_is_overtaken(self) -> None: + store = gateway.RegisterStore(self.MAPPINGS) + self._publish(store, -3, 100.0) + ticks = iter(range(-2, 60)) + + def publish_every_time() -> None: + self._publish(store, next(ticks), 300.0) + + client = self._client(store, publish_every_time) + status = self._start(client) + started = time.monotonic() + + result = ingestion.poll_consistent_batch( + client, self.MAPPINGS, status, started + 0.35, 5 + ) + + assert result is None + assert time.monotonic() - started < 1.0 + + def test_gives_up_when_the_update_never_completes(self) -> None: + store = gateway.RegisterStore(self.MAPPINGS) + self._publish(store, -1, 100.0) + client = self._client(store, store.clear_heartbeat) + status = self._start(client) + + result = ingestion.poll_consistent_batch( + client, self.MAPPINGS, status, time.monotonic() + 2.5, 5 + ) + + assert result is None diff --git a/data/upgrade/Dockerfile b/data/upgrade/Dockerfile new file mode 100644 index 0000000..7a143bd --- /dev/null +++ b/data/upgrade/Dockerfile @@ -0,0 +1,22 @@ +# Build a temporary tool image with both PostgreSQL versions for pg_upgrade. +# This image never runs an application service. +ARG OLD_IMAGE=timescale/timescaledb@sha256:0f5553d37abbe0f20bfe0a04dab5d1238955b5cda2711aba403090f95b6e51ff +ARG NEW_IMAGE=timescale/timescaledb@sha256:6f139d56042989bd35f50ba5986e492cd32e35cc6778c50be2659add298dc09f +FROM ${OLD_IMAGE} AS previous +FROM ${NEW_IMAGE} + +COPY --from=previous /usr/local/ /opt/pg14/ +# The older Alpine image uses older ICU libraries. Keep its dependencies private +# to the old binaries so the new server continues using its own libraries. +COPY --from=previous /usr/lib/ /opt/pg14-deps/ +COPY old-binary.sh /opt/old-binary.sh +RUN chmod 755 /opt/old-binary.sh \ + && mkdir /opt/old-bin /upgrade \ + && chown postgres:postgres /upgrade \ + && for program in /opt/pg14/bin/*; do \ + ln -s /opt/old-binary.sh /opt/old-bin/${program##*/}; \ + done + +USER postgres +WORKDIR /upgrade +ENTRYPOINT ["pg_upgrade"] diff --git a/data/upgrade/old-binary.sh b/data/upgrade/old-binary.sh new file mode 100644 index 0000000..d74ffba --- /dev/null +++ b/data/upgrade/old-binary.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Load dependencies for the old server without affecting the new server. +set -eu +export LD_LIBRARY_PATH=/opt/pg14-deps:/opt/pg14/lib +exec "/opt/pg14/bin/${0##*/}" "$@" diff --git a/docker-compose.yml b/docker-compose.yml index 761c81d..5c6c225 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: db: - image: timescale/timescaledb:latest-pg14 + # PostgreSQL 16.15 / TimescaleDB 2.30.2. See the engine upgrade design. + image: timescale/timescaledb@sha256:6f139d56042989bd35f50ba5986e492cd32e35cc6778c50be2659add298dc09f container_name: wellhead_db environment: - POSTGRES_USER=${POSTGRES_USER} @@ -38,8 +39,8 @@ services: # networks: # - wellnet - # Applies the ordered SQL migrations, then exits. Every service that reads or - # writes the schema waits for it to finish successfully. + # Installs or checks the current schema, then exits. Existing legacy databases + # require an explicit, verified adoption before this default startup check. migrator: build: ./data/src container_name: wellhead_migrator @@ -58,6 +59,8 @@ services: POSTGRES_PORT: "5432" TWIN_DB_USER: ${TWIN_DB_USER} TWIN_DB_PASSWORD: ${TWIN_DB_PASSWORD} + API_DB_USER: ${API_DB_USER:?Set API_DB_USER to a separate application login} + API_DB_PASSWORD: ${API_DB_PASSWORD:?Set API_DB_PASSWORD} networks: - wellnet @@ -65,6 +68,7 @@ services: build: ./twin_core container_name: well_simulation_twin_core restart: unless-stopped + stop_grace_period: 15s depends_on: db: condition: service_healthy @@ -77,6 +81,7 @@ services: TWIN_DB_USER: ${TWIN_DB_USER} TWIN_DB_PASSWORD: ${TWIN_DB_PASSWORD} TWIN_SERVICE_PORT: "8000" + TWIN_INTERNAL_TOKEN: ${TWIN_INTERNAL_TOKEN} TWIN_MAX_CATCHUP_STEPS: "5" TWIN_FLEET_REFRESH_SECONDS: "30" TELEMETRY_INTERVAL_SECONDS: ${TELEMETRY_INTERVAL_SECONDS:-5} @@ -153,7 +158,9 @@ services: condition: service_completed_successfully environment: NODE_ENV: production - DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + DATABASE_URL: postgres://${API_DB_USER}:${API_DB_PASSWORD}@db:5432/${POSTGRES_DB} + TWIN_CORE_URL: http://twin-core:8000 + TWIN_INTERNAL_TOKEN: ${TWIN_INTERNAL_TOKEN} JWT_SECRET: ${JWT_SECRET} COOKIE_SECURE: ${COOKIE_SECURE:-false} # The dashboard's nginx is the only thing that should speak for a client. @@ -169,7 +176,7 @@ services: - CMD - node - -e - - "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + - "fetch('http://127.0.0.1:3000/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" interval: 15s timeout: 3s retries: 3 @@ -188,18 +195,81 @@ services: ports: - "8090:80" restart: unless-stopped + # The image checks both the static page and /api/ready through nginx. + networks: + - wellnet + + prometheus: + image: prom/prometheus@sha256:87861b8cf91579109319ebc300f3f1060e6da9c05d6ae8ad15a20c879e84e32e + profiles: [observability] + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.listen-address=0.0.0.0:9090 + ports: + - "127.0.0.1:9090:9090" + volumes: + - ./observability/prometheus:/etc/prometheus:ro + - prometheus_data:/prometheus + cap_drop: [ALL] + security_opt: [no-new-privileges:true] healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:9090/-/ready] interval: 15s - timeout: 3s + timeout: 5s retries: 3 - start_period: 10s - networks: - - wellnet + start_period: 20s + restart: unless-stopped + networks: [wellnet] + + grafana: + image: grafana/grafana@sha256:dbbc38ea21103cefb50396340cd88afec779c7e070b0a7bf5a75b4ce46651d3f + profiles: [observability] + entrypoint: [/bin/sh, /etc/grafana/start.sh] + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-} + GF_USERS_ALLOW_SIGN_UP: "false" + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_ANALYTICS_REPORTING_ENABLED: "false" + GF_ANALYTICS_CHECK_FOR_UPDATES: "false" + GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false" + GF_PLUGINS_PREINSTALL_DISABLED: "true" + GF_PLUGINS_PREINSTALL_AUTO_UPDATE: "false" + GF_PLUGINS_PLUGIN_ADMIN_ENABLED: "false" + GF_USERS_DEFAULT_THEME: light + GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/service-health.json + GF_LOG_MODE: console + GF_LOG_CONSOLE_FORMAT: json + ports: + - "127.0.0.1:3010:3000" + volumes: + - ./observability/grafana/start.sh:/etc/grafana/start.sh:ro + - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro + - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + depends_on: + prometheus: + condition: service_healthy + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + healthcheck: + test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:3000/api/health] + interval: 15s + timeout: 5s + retries: 3 + start_period: 180s + restart: unless-stopped + networks: [wellnet] volumes: timescaledb_data: + # Keep the existing default name. A major-version upgrade selects its + # verified new volume explicitly, preserving the original for recovery. + name: ${TIMESCALE_DATA_VOLUME:-${COMPOSE_PROJECT_NAME}_timescaledb_data} pgadmin_data: + prometheus_data: + grafana_data: networks: wellnet: diff --git a/docs/README.md b/docs/README.md index d0ffeb0..b67efd7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,61 +1,101 @@ # Documentation -Product framing, architecture notes, the production readiness checklist, and the -architecture decision records. +This project simulates wellhead monitoring, model analysis and reviewed operating +actions. Its process model is synthetic and uncalibrated. It has no physical +control connection. Call it a wellhead monitoring simulation, not a finished +field digital twin. -## What to call this project +## Start here -Call it a wellhead monitoring simulation, or a SCADA-style industrial telemetry -platform. - -Do not call it a finished digital twin yet. It has a stateful synthetic process -model, but no field calibration, durable model snapshots, forecast API, or -what-if scenarios. +- [Run the application](../README.md#quick-start): setup and demo account. +- [Follow the demo](demo/demo-script.md): monitoring, saved comparisons and optional operating workflows. +- [Read the architecture](architecture/overview.md): service boundaries and current diagrams. +- [Check readiness and open work](architecture/production-readiness.md): evidence, limitations and delivery gates. +- [Develop the web application](../monorepo/README.md): local environment and verification commands. ## What is built -- Synthetic wellhead telemetry -- Modbus ingestion -- A PostgreSQL and TimescaleDB historian -- Alarm evaluation in SQL -- A public TypeScript API -- A separate twin-core service with an uncalibrated per-well process model -- A React dashboard with an overview, analytics, per-wellhead detail pages, and - an Alarm Center - -The Alarm Center shows active alarms and their thresholds. It does not offer -acknowledge or shelve actions because the data model cannot store those states. - -## Contents - -- `architecture/overview.md`: the current architecture and the target one -- `architecture/production-readiness.md`: what production-grade means here -- `adr/README.md`: the ADR format and the index -- `demo/demo-script.md`: the demo flow for a portfolio review -- `design/twin-core-milestone-4.md`: the service boundary and its limits -- `design/twin-core-milestone-5.md`: the process model equations and limits -- `design/twin-core-milestone-6.md`: model telemetry through Modbus -- `design/reference-well-physics.md`: phase and diagnostic equations -- `openapi/README.md`: the API contracts and the rules for changing them -- `roadmap.md`: the local feature plan (ignored by Git) -- `remediation-roadmap.md`: the local repair record (ignored by Git) - -## Documentation backlog - -- Keep the README matching what is actually built. -- Write the ADR before adding major twin behaviour, not after. -- Update the OpenAPI spec with every public API change. -- Keep model assumptions visible wherever synthetic readings appear. -- Keep the demo honest. Show what works, then say what is planned. - -## How we write docs here - -- Every major architectural choice gets an ADR. -- Each ADR lists the options that were considered. -- Each ADR says why the chosen option won. -- Rejected options stay on the record. They are the reasoning. -- Docs say plainly what is built and what is only planned. -- Prose follows the plain English standard in - the project's coding standards: short sentences, everyday words, active - voice, and no em dashes. `scripts/check_prose.py` enforces the checkable - parts. +Twin-core calculates all 18 seeded signals, serves them through Modbus and saves +its state for restart recovery. Ingestion stores source-labelled readings in +TimescaleDB. SQL evaluates alarm conditions. + +The dashboard connects fleet monitoring with forecasts, historical scenarios, +saved results and administrator-owned forecast schedules. OPERATIONS users can +review live synthetic controls, acknowledge exact alarm events and manage timed +shelves. ADMIN users can publish model parameters and create accounts. Parameter +publication requires a model-service restart. Optional Prometheus and Grafana +services show application health and metrics. + +## Architecture and decisions + +- [Architecture overview](architecture/overview.md) +- [Production readiness](architecture/production-readiness.md) +- [ADR index](adr/README.md): accepted decisions, amendments and implementation status. +- [API contracts](openapi/README.md): public Hono API and internal twin-core HTTP API. +- [Synthetic reference-well physics](design/reference-well-physics.md): equations, units and limits. + +## Model and feature designs + +| Design | What it covers | +| --- | --- | +| [Milestone 4](design/twin-core-milestone-4.md) | Service boundary and initial lifecycle | +| [Milestone 5](design/twin-core-milestone-5.md) | Original process equations and units | +| [Milestone 6](design/twin-core-milestone-6.md) | Model telemetry through Modbus | +| [Milestone 7](design/twin-core-milestone-7.md) | Snapshots, parameters and restart recovery | +| [Milestone 8](design/twin-core-milestone-8.md) | Forecasts, scenarios and saved results | +| [Milestone 8B](design/twin-core-milestone-8b.md) | Reviewed control commands and parameter publication | +| [Historical scenarios](design/historical-scenarios.md) | Exact snapshot selection and compatible branches | +| [Scheduled forecasts](design/scheduled-forecasts.md) | Ownership, bounded jobs and automatic saved results | +| [Alarm lifecycle](design/alarm-lifecycle.md) | Acknowledgement, shelving, receipts and condition state | + +Milestone documents record their original scope and dated verification. Later +designs extend that scope. Use the current architecture and ADR index to establish +what runs now; a test result from an earlier milestone does not cover later work. + +## Interface design and verification + +- [Milestone 9 workspace](design/twin-core-milestone-9.md): design completion gate and browser workflow. +- [Visual specification](design/milestone-9/visual-specification.md): spacing, type, colours, charts and responsive states. +- [Original mockup gallery](design/milestone-9/mockups/index.html): illustrative screen states. +- [Accessibility verification](design/milestone-9/accessibility-verification.md): checks run and combinations not tested. +- [Usability follow-up](design/dashboard-usability.md): Guide, page explanations and local delivery checks. +- [Operating workspace](design/operating-workspace.md): control and parameter forms, reviews and outcome recovery. + +Historical-scenario, schedule, alarm and observability designs link their own +mockups and verification. Application screenshots live in `screenshots/`; +each design identifies captures that use browser-test fixtures. + +## Database and service operation + +- [Data layer](../data/README.md): telemetry, schema ownership and service accounts. +- [Existing-database update guide](operations/database-updates.md): choose the correct adoption, engine or extension path. +- [Schema consolidation](design/consolidated-schema.md): the single SQL source and checked legacy adoption. +- [Engine upgrade](design/database-engine-upgrade.md): preserved volumes and recovery verification. +- [Schema extension](design/schema-extension.md): verified additions to existing installations. +- [Retention policy](adr/0026-retention-and-compression.md#amendment-2026-10-17-record-the-installed-retention-policy): actual retention and durable records. +- [Service metrics design](design/observability.md) and [runbook](../observability/README.md): optional Prometheus and Grafana. +- [Security policy](../.github/SECURITY.md): private vulnerability reporting. + +## Local planning notes + +`roadmap.md` records feature work and `remediation-roadmap.md` records the repair +history. Both remain ignored by Git and are absent from a clean checkout. +The committed README, readiness record and ADR index provide current status +without requiring those local notes. `github-hardening-checklist.md` is also an +ignored reference from another project, not this repository's delivery status. + +## Maintaining these documents + +Write for someone who has not seen the project. Use active voice, ordinary words +and concrete examples. Define technical terms when first needed. Avoid em dashes. +`scripts/check_prose.py` checks the mechanical parts of that style. + +Keep setup commands consistent with the current scripts and configuration. +Update API specifications with route changes. Append dated ADR amendments when +a decision changes, preserve the original reasoning and update the index status. +State which checks actually ran and keep local results separate from remote CI. + +Complete relevant mockups, loading and failure states, shared visual rules and +responsive behavior before implementing a new screen. Follow the +[design completion gate](design/twin-core-milestone-9.md#design-completion-gate) +and record accessibility checks during implementation. diff --git a/docs/adr/0001-project-positioning.md b/docs/adr/0001-project-positioning.md index b54d011..8541058 100644 --- a/docs/adr/0001-project-positioning.md +++ b/docs/adr/0001-project-positioning.md @@ -1,6 +1,6 @@ # ADR 0001: Position The Project As A Wellhead Monitoring Simulation Before Digital Twin -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 - Implementation: Implemented @@ -9,3 +9,22 @@ The project currently simulates wellhead telemetry, exposes it through Modbus TCP, stores readings in TimescaleDB, evaluates alarm rules, and has a dashboard/API foundation. It does not yet include a stateful process model, prediction, calibration, or what-if simulation. It is on the road to becoming a digital twin. + +## Amendment 2026-10-17: Describe the implemented synthetic system + +The context above describes the original project. Twin-core now calculates all +18 seeded signals, saves state for recovery, and runs forecasts and isolated +scenarios. The dashboard includes historical branches, scheduled forecasts, +reviewed synthetic controls, parameter publication and alarm operator actions. + +Keep the wellhead monitoring simulation name. The implemented model has no field +calibration or physical control connection. A completed software workflow does +not establish accuracy for a real well. The +[architecture overview](../architecture/overview.md) records the current system. + +**Good.** Readers can distinguish completed model features from field validation. + +**Costs.** Capability descriptions need updates when another workflow changes. + +**Accepted risks.** Synthetic results can look plausible without being accurate; +model documentation and result metadata must retain their stated limits. diff --git a/docs/adr/0002-lightweight-digital-twin-architecture.md b/docs/adr/0002-lightweight-digital-twin-architecture.md index 03d9128..c67826f 100644 --- a/docs/adr/0002-lightweight-digital-twin-architecture.md +++ b/docs/adr/0002-lightweight-digital-twin-architecture.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Implemented for the synthetic reference well ## Context @@ -39,3 +39,11 @@ The goal is to demonstrate industrial digital twin concepts in a production-grad - Model fidelity must be documented honestly. - The dashboard should show modeled state and predictions separately from raw readings. + +## Implementation note, 2026-10-17 + +The dedicated model now feeds Modbus and the historian. The dashboard separates +measured telemetry views, model state, forecasts, isolated scenarios and live +model controls. This completes the selected layered architecture for the +synthetic reference well. Field calibration and physical device control remain +outside this implementation. diff --git a/docs/adr/0003-stateful-process-model.md b/docs/adr/0003-stateful-process-model.md index 530fe2e..1687d82 100644 --- a/docs/adr/0003-stateful-process-model.md +++ b/docs/adr/0003-stateful-process-model.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Implemented ## Context @@ -50,3 +50,11 @@ Milestone 6 replaced the random Modbus source with the current model state. The gateway now reads one complete model fleet snapshot. Ingestion stores the supported values with a model source label. Durable model state snapshots remain Milestone 7 work. + +## Implementation on 2026-10-13 + +The synthetic process model now supplies all 18 seeded Modbus signals. +Milestone 7 stores snapshots and recovery events. Milestone 8 uses the same +one-second step for saved forecasts and isolated scenarios. Model response, +recovery, and projection tests cover these paths. Field calibration remains +outside the implemented synthetic reference-well scope. diff --git a/docs/adr/0005-data-historian.md b/docs/adr/0005-data-historian.md index d0ab25d..65ceca7 100644 --- a/docs/adr/0005-data-historian.md +++ b/docs/adr/0005-data-historian.md @@ -1,6 +1,6 @@ # ADR 0005: Use PostgreSQL And TimescaleDB As The Historian -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 - Implementation: Implemented @@ -39,3 +39,40 @@ The twin needs asset metadata, readings, alarms, model state snapshots, predicti - Continuous aggregates should be treated as part of migration-managed schema. - Data retention, compression, and indexing strategies must be documented before production. +## Amendment 2026-10-16: Verify the engine and its recovery path + +### Context + +TimescaleDB 2.19.3 failed to restore a compressed null value in the historian. +A physical backup recovered successfully, but the logical restore remained +broken. PostgreSQL 14 cannot run the TimescaleDB release containing the fix. + +### Decision + +Use PostgreSQL 16.15 with TimescaleDB 2.30.2, pinned by image digest in Compose +and CI. Upgrade PostgreSQL using pg_upgrade in copy mode while the extension +remains at 2.19.3, then update the extension. Rehearse on a verified backup. +Require preserved data and effective permissions, successful logical restores, +and application tests before switching the running stack to its converted volume. +The schema remains one SQL file under the amended [ADR 0006](0006-schema-management.md). + +Keeping the old engine with physical backups was a valid fallback. Decompressing +all old data before a logical transfer was another option, but would rewrite +the historian and require more space. Copy-mode pg_upgrade avoids the broken +restore decoder and preserves the original data files for recovery. + +### Consequences + +**Good.** Backup restoration becomes a checked release condition. The original +volume remains available while the converted copy is validated. + +**Costs.** The procedure needs both PostgreSQL binaries, separate data volumes, +and a pause in application writes for the final conversion. + +**Accepted risks.** Returning to the old volume after new writes begin would +omit those writes. Recovery must account for them explicitly. Engine versions +remain pinned until their replacements pass the same checks. + +The [engine upgrade design](../design/database-engine-upgrade.md) records the +procedure, source references, failures, and verification results. + diff --git a/docs/adr/0006-schema-management.md b/docs/adr/0006-schema-management.md index c957459..b060de4 100644 --- a/docs/adr/0006-schema-management.md +++ b/docs/adr/0006-schema-management.md @@ -1,8 +1,8 @@ -# ADR 0006: Use SQL Migrations As The Canonical Schema Source +# ADR 0006: Keep SQL As The Canonical Schema Source -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Implemented +- Implementation: Implemented and adopted on the local database ## Context @@ -42,3 +42,116 @@ This project is database-heavy and depends on PostgreSQL/TimescaleDB capabilitie - CI should verify migrations apply cleanly. - Schema changes require both SQL migration updates and Drizzle type updates. +## Amendment 2026-10-13: Maintain one current schema file + +### Context + +The owner asked on 2026-10-03 to combine all schema definitions into one file +and questioned whether that file must be SQL. The follow-up requested more +explanation. The discussion remained unresolved while more files were added. +This preference must be considered before further schema work. + +### Decision + +The owner selected one SQL schema file on 2026-10-13. Maintain the complete +current database definition in data/sql/schema.sql. The owner authorized +implementation after choosing this format. +Fold old column additions and constraint corrections into the final definitions. +Keep demo seed records separate from database structure. Python continues to +own installation, verification, generated credentials, and configured grants. + +Adopt an existing, fully current database only after checking both its recorded +history and actual structure against the consolidated definition. Preserve its +readings, application records, and old schema_migrations ledger. Do not replay +historical view drops or replace the existing Docker volume. + +The installer provides fresh installation, checking, and safe adoption. +It does not infer arbitrary upgrades after later schema edits. +Changes to an existing database still require a reviewed conversion plan. + +### Options considered + +One SQL file provides a direct definition of the TimescaleDB objects already +used here. A Python or TypeScript source is possible, but still needs SQL for +database-specific functions and policies. A comparison tool may help with future +upgrades, but must first prove coverage for the project's TimescaleDB objects. + +### Consequences + +**Good.** One file contains the complete current schema. Startup compares the +actual database with a generated reference and rejects structural drift. + +**Costs.** The reference must be regenerated from an empty database after a +schema change. Existing data still needs a tested conversion plan. Version +changes require new reference verification; Compose and CI use a pinned image. + +**Accepted risks.** The installer cannot infer arbitrary data conversions. +An interrupted installation requires investigation, because some TimescaleDB +statements cannot share one transaction. It stops instead of replaying them. + +### Reversal + +The numbered schema files are replaced by the current definition. Git preserves +their source history; adopted databases retain their original ledger entries. +Do not reintroduce a numbered corrective-file chain as the default authoring +workflow. A generated overview beside the old chain would not satisfy this decision. + +The [consolidation design](../design/consolidated-schema.md) records the +layout, installer behavior, failure cases, and verification gates. The original +decision above remains as history; this amendment governs new schema work. + +## Amendment 2026-10-16: Accept verified engine changes explicitly + +The generated reference includes engine versions. A reviewed engine upgrade +therefore needs an explicit acceptance step even when schema.sql stays unchanged. +The accept-engine command requires the saved previous reference to match the +installed identity, preserves the SQL and legacy-history checksums, and verifies +the current engine and actual database structure against the new reference. +It updates the recorded identity and writes an audit event in one transaction. + +Normal startup still refuses a mismatched identity. Acceptance does not apply +schema statements or replace backup and application testing. Comparison allows +the verified PostgreSQL difference between casting a list of text values as a +whole and casting each value individually. It still detects changed values, +types, expressions, and unrelated structure. + +This adds an explicit operational step and tests for engine compatibility. It +keeps one SQL source and avoids adding a new numbered-file workflow. See the +[engine upgrade design](../design/database-engine-upgrade.md) for its checks. + + +## Amendment 2026-10-17: Verify additions to the current schema + +### Context + +Scheduled forecasts and alarm lifecycle records need new ordinary tables. The +single canonical SQL file remains the owner's chosen workflow. Existing data must +survive additions without replaying the full bootstrap or creating a migration chain. + +### Decision + +Add an explicit `extend` installer mode. Supply the previous committed schema and +contract as temporary references. Require an exact source prefix, unchanged engine +and legacy identity, unchanged existing catalog definitions, and a permitted suffix +containing only new ordinary tables and their indexes. Apply that suffix, verify +the complete generated target catalog and record the new identity and audit event +in one transaction. Provision service grants afterward. Repeating a completed +extension checks its target without replaying DDL. + +### Consequences + +Fresh installations still use the complete `data/sql/schema.sql`. Existing +installations take a private backup and verify a restored copy before extending. +This mode refuses changes to existing tables or functions; those changes need a +separate reviewed design. Previous files remain temporary release references, +not maintained alternate schemas. See the [extension design](../design/schema-extension.md). + +## Restore comparison update, 2026-10-17 + +A full backup rehearsal exposed a PostgreSQL parser difference in four scheduled +forecast duration checks. Restoring the dump flattens the nested AND produced +by BETWEEN, while retaining the same bounds and divisibility test. The schema +comparison recognizes only that exact verified expression shape. It still checks +constraint names, validation flags and every operand, and rejects other changes. +The canonical SQL, its checksum and the exact previous-source requirement remain +in force. See the [extension follow-up](../design/schema-extension.md#restore-comparison-follow-up-2026-10-17). diff --git a/docs/adr/0007-control-and-what-if-boundaries.md b/docs/adr/0007-control-and-what-if-boundaries.md index 28f6e8e..9b22d88 100644 --- a/docs/adr/0007-control-and-what-if-boundaries.md +++ b/docs/adr/0007-control-and-what-if-boundaries.md @@ -1,8 +1,8 @@ # ADR 0007: Separate Live Control From What-If Simulation -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Not started +- Implementation: Separate scenario, live control and parameter screens implemented ## Context @@ -41,3 +41,33 @@ Production-grade digital twin software should make mutation boundaries obvious. - Scenario runs should have isolated inputs and outputs. - Dashboard UI must visually distinguish live state from scenario results. + +## Implementation on 2026-10-13 + +Milestone 8 runs scenarios against private copies of current model state. +They cannot mutate the running model. Live control remains proposed in +[ADR 0038](0038-live-control-command-lifecycle.md). + +## Implementation on 2026-10-13: Live synthetic controls + +Milestone 8B adds a separate draft and activation API. Twin-core applies a +confirmed command at a model tick and restores its durable controls after a +restart. An integration test runs a scenario after a live change and confirms +that the active controls and revision remain unchanged. Dashboard controls +remain outside this milestone. + +## Implementation on 2026-10-16: Dashboard analysis + +Milestone 9 presents installed controls as read-only values in Monitoring and +places scenario inputs on a separate Scenarios page. Saved results show the +original baseline and schedule. Editing a new draft does not alter a saved +result. Live command entry still uses the separate draft and activation API; +this dashboard does not add live command forms. + +## Amendment, 2026-10-17: separate operating views + +The owner requested live control and parameter screens after the usability pass. +Controls now uses the durable proposal and activation API. Parameters uses the +administrator proposal and publication API. Both require an explicit review step. +Scenarios still run against isolated state. The [operating design](../design/operating-workspace.md) +records their layouts, outcome recovery and role restrictions. diff --git a/docs/adr/0009-model-fidelity.md b/docs/adr/0009-model-fidelity.md index e150269..55d9640 100644 --- a/docs/adr/0009-model-fidelity.md +++ b/docs/adr/0009-model-fidelity.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Implemented ## Context @@ -66,3 +66,11 @@ The first version should model: ## Implementation on 2026-09-29 The reduced-order deterministic model now runs in twin-core. Existing Modbus telemetry is still random until Milestone 6 connects the sources. + +## Implementation on 2026-10-13 + +The synthetic process model now supplies all 18 seeded Modbus signals. +Milestone 7 stores snapshots and recovery events. Milestone 8 uses the same +one-second step for saved forecasts and isolated scenarios. Model response, +recovery, and projection tests cover these paths. Field calibration remains +outside the implemented synthetic reference-well scope. diff --git a/docs/adr/0010-model-runtime.md b/docs/adr/0010-model-runtime.md index 4a7fe38..e602834 100644 --- a/docs/adr/0010-model-runtime.md +++ b/docs/adr/0010-model-runtime.md @@ -47,3 +47,10 @@ Python is the best fit for the model layer now and leaves room for NumPy, SciPy, ## Implementation on 2026-09-29 The separate Python service runs in Docker Compose. It owns its own state, scheduler, internal HTTP listener, logs, and metrics. The process equations remain Milestone 5 work. + +## Implementation update on 2026-10-17 + +The process equations, snapshot recovery, private projections and durable control +application now run in this service. Scheduled forecasts use a worker thread +inside the same service. The deployment boundary remains unchanged. See the +[architecture overview](../architecture/overview.md) for the current data paths. diff --git a/docs/adr/0011-twin-core-communication.md b/docs/adr/0011-twin-core-communication.md index 13c7759..bffff34 100644 --- a/docs/adr/0011-twin-core-communication.md +++ b/docs/adr/0011-twin-core-communication.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Implemented ## Context @@ -54,3 +54,18 @@ The Modbus gateway now reads a complete, read-only model fleet snapshot over internal HTTP. It converts the supported model fields into Modbus registers; ingestion still polls Modbus and writes source-labelled readings to TimescaleDB. Durable model state snapshots, commands, forecasts, and scenarios remain planned. + +## Implementation on 2026-10-13 + +Milestones 7 and 8 now persist model snapshots and complete forecast and scenario +runs. Internal HTTP accepts bounded analysis requests using a service token; +the public API checks the human session. Live commands remain proposed in +[ADR 0038](0038-live-control-command-lifecycle.md). + +## Implementation on 2026-10-13: Command delivery + +Milestone 8B uses authenticated internal HTTP for command wake-up hints and +parameter validation. A command UUID identifies a committed database record; +the hint does not carry controls or human authority. The inbox also polls the +durable queue, so a missed hint does not lose an accepted command. Database +transactions own activation, application, and their matching audit or event rows. diff --git a/docs/adr/0012-state-storage.md b/docs/adr/0012-state-storage.md index 97ab756..2b96694 100644 --- a/docs/adr/0012-state-storage.md +++ b/docs/adr/0012-state-storage.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Complete for the single-instance synthetic model ## Context @@ -51,3 +51,21 @@ The service keeps active lifecycle state in memory. No physical process state or ## Implementation on 2026-09-29 The process state now advances in memory. A restart deliberately initializes a new synthetic run and reports `cold_start_no_snapshot`. Durable snapshots remain Milestone 7 work. + +## Implementation on 2026-10-12 + +Milestone 7 adds a TimescaleDB snapshot about every 30 model seconds and a +final copy on clean shutdown. The one-slot writer mailbox keeps database +latency off the one-second model loop. On startup, twin-core checks the latest +snapshot for each active well and restores compatible state. A rejected or +missing snapshot starts that well cold with a recorded reason. + +## Implementation correction on 2026-10-12 + +The first shutdown path queued the final copy behind any earlier database +write and waited only 3 seconds. The final copy could remain unsaved. After the +model loop stops, twin-core now writes that copy directly through a bounded +database connection. Periodic writes still use the background writer to keep +the one-second model loop independent of database latency. A failed final write +is logged and counted, and the service exits nonzero. Recovery can still use +the latest successful periodic snapshot. diff --git a/docs/adr/0013-prediction-strategy.md b/docs/adr/0013-prediction-strategy.md index 943c5f1..7a9c5d7 100644 --- a/docs/adr/0013-prediction-strategy.md +++ b/docs/adr/0013-prediction-strategy.md @@ -1,8 +1,8 @@ # ADR 0013: Start With On-Demand Forecasts And Evolve To Hybrid Forecasting -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Not started +- Implementation: On-demand and scheduled forecasts implemented ## Context @@ -39,3 +39,39 @@ On-demand forecasting is the fastest way to validate model behavior. Scheduled f - Forecast outputs should be persisted. - Later scheduled forecasts should populate dashboard-ready latest predictions. +## Implementation on 2026-10-13 + +Milestone 8 adds an on-demand forecast route in twin-core and an authenticated +public API route. A request copies current running state and controls, advances +the existing one-second model for a bounded horizon, and saves the full run and +sampled points. There is no background forecast scheduler yet. + + +## Amendment 2026-10-17: Add owned, bounded schedules + +### Context + +The owner approved scheduled forecasts after the dashboard, operating screens and +historical scenario work. The existing model and saved-run boundary now support +short automatic forecasts without changing live controls. + +### Decision + +Keep the on-demand workflow and add one administrator-owned schedule per well. +Schedules start disabled, use intervals of 5–1440 minutes and forecast 5–30 minutes +ahead. A separate worker claims one occurrence at a time, skips missed intervals +and shares the existing projection capacity. It rechecks active administrator +authority before claiming and before saving. Logout does not cancel owned schedules. + +Save the forecast, its points and job completion in one transaction. Expired or +interrupted claims fail without automatic replay. A saved result identifies the +schedule, job and due time. Readers see the latest attempt separately from the +latest completed result. Settings changes require a reason, confirmation and +current revision, with an atomic audit event. + +### Consequences + +The earlier on-demand-only implementation remains part of the history. The current +implementation is hybrid. Schedules add database and calculation load, bounded by +configuration limits, one worker and shared projection slots. They do not provide +field calibration or guaranteed risk detection. See the [design and verification](../design/scheduled-forecasts.md). diff --git a/docs/adr/0014-scenario-execution.md b/docs/adr/0014-scenario-execution.md index acc2f6f..05dd6d6 100644 --- a/docs/adr/0014-scenario-execution.md +++ b/docs/adr/0014-scenario-execution.md @@ -1,8 +1,8 @@ # ADR 0014: Run What-If Scenarios Against Isolated Cloned State -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Not started +- Implementation: Current and verified historical snapshot scenarios implemented ## Context @@ -44,3 +44,33 @@ This creates a safe and production-grade boundary between experiments and the li - Scenario metadata, inputs, and result points should be persisted. - The UI must distinguish live state, forecasts, and scenario outputs. +## Implementation on 2026-10-13 + +Milestone 8 copies the current state, controls, and parameters under one registry +lock. It applies the requested controls only to a private model run. An unchanged +private baseline runs beside it, and both sets of points are saved in one +transaction. The live registry never receives the scenario controls. Starting +from a historical snapshot remains deferred under ADR 0027. + +## Amendment 2026-10-17: Historical scenarios are implemented + +The deferral in the dated note above ended with the historical-scenario work. +A caller can now select an exact retained snapshot using its ID and simulated +time. Twin-core checks the model version, parameter fingerprint and physical +state, then creates private scenario and unchanged-baseline copies. An invalid +selection never falls back to current state. + +The saved result records its starting-state identity. It uses alarm rules active +at execution; it does not reconstruct past rule settings. Chart retention follows +simulated time. Current-state scenarios keep their existing behavior. + +**Good.** Users can reopen an attributed comparison from an earlier model state. + +**Costs.** Snapshot compatibility and exact timestamp handling add validation. + +**Accepted risks.** Parameter changes can make old snapshots incompatible, and +historical points can expire sooner than points from a current-state scenario. + +See [ADR 0027](0027-historical-branching.md) for the amended boundary and +[historical scenarios](../design/historical-scenarios.md) for design and verification. + diff --git a/docs/adr/0015-control-and-safety.md b/docs/adr/0015-control-and-safety.md index 6e200c1..991415c 100644 --- a/docs/adr/0015-control-and-safety.md +++ b/docs/adr/0015-control-and-safety.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-05-31 -- Implementation: Not started +- Implementation: Implemented for the synthetic model ## Context @@ -47,3 +47,14 @@ This keeps the twin realistic. Operators change controls; the process responds. - Direct edits to process outcomes are not allowed. - Emergency shutdown should override normal control behavior. +## Implementation on 2026-10-13 + +Milestone 8B exposes only choke position, pump state, master valve, wing valve, +and emergency shutdown through the live command API. Process outcomes remain +model outputs. Shutdown and reset each require their own command with no other +target. A shutdown must be reset before another control can change. + +[ADR 0038](0038-live-control-command-lifecycle.md) records the draft, +confirmation, current-authority checks, audit, expiry, and restart behavior. +These controls affect only the synthetic model; no physical safety claim is made. + diff --git a/docs/adr/0017-observability-baseline.md b/docs/adr/0017-observability-baseline.md index cad1321..db0a0dc 100644 --- a/docs/adr/0017-observability-baseline.md +++ b/docs/adr/0017-observability-baseline.md @@ -1,6 +1,6 @@ # ADR 0017: Use Structured Logs, Health Checks, And Metrics As The Observability Baseline -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 - Implementation: Implemented @@ -45,3 +45,61 @@ This gives enough operational visibility to run and debug the system while avoid - Metrics should cover model ticks, Modbus reads, ingestion writes, API errors, forecasts, scenarios, and controls. - OpenTelemetry tracing is deferred. +## Amendment 2026-10-16: Check the dashboard's API path + +### Context and decision + +After the database upgrade, the API had a new container address. The dashboard +still served its page, but nginx sent API requests to the old address. +Every individual service reported healthy while users received 502 responses. + +The dashboard image now checks its static page and /api/ready through nginx. +The API readiness check includes its database connection. Docker reports the +dashboard unhealthy when either check fails. Do not reintroduce a static-page-only +health check in Compose; the image owns this command. + +Nginx refreshes the API service address through Docker's DNS resolver. Its +shared upstream uses a five-second DNS validity and a two-second lookup timeout. +An API connection attempt times out after three seconds. Proxy request retries +are disabled, so a lost command response cannot trigger a second command attempt. +The image pins the verified nginx 1.27.5 digest, which supports dynamic upstreams. + +Manual reloads were a useful recovery step. Fixed API addresses would also avoid +the observed failure, but would couple deployment to address allocation. Dynamic +resolution follows Docker's service-name contract without that coupling. + +### Consequences and verification + +**Good.** API replacement recovers without a dashboard restart. Readiness tests +the path a browser uses, including the API's database dependency. + +**Costs.** This image expects Docker's internal DNS at 127.0.0.11. Another runtime +needs its own trusted resolver. The health check adds two local HTTP requests +every fifteen seconds and becomes unhealthy during API maintenance. + +**Accepted risks.** Requests can fail while DNS refreshes or the API is offline. +The client must display that failure and check command status before resubmitting. +Readiness does not establish sensor freshness; telemetry carries its own timestamps. + +The isolated regression command is python3 scripts/verify_proxy_recovery.py after +building the service images. It uses real Docker DNS, a real API address change, +the shipped nginx configuration, and the image's health check. A small HTTP fixture +supplies deterministic responses without connecting to the application database. +CI runs the same check after building images. + +Verified locally on 2026-10-16: startup without an API, late API startup, +request forwarding, a dropped command response without replay, readiness failure, +and recovery after a real API address change all passed. The last check recovered +in 6.8 seconds without restarting nginx, using a two-second test health interval. +That duration is test evidence, not a production recovery guarantee. + +The first regression attempt encountered a connection reset while nginx started. +The bounded startup wait now accepts that temporary connection failure, and the +rerun passed. After deployment, all six services reported healthy, /api/ready +returned 200 through nginx, and the overview, analytics, alarms, and wellhead pages +passed a browser smoke check without browser errors. Remote CI has not run yet. + +References: [nginx upstream resolution](https://nginx.org/en/docs/http/ngx_http_upstream_module.html#server), +[Docker DNS](https://docs.docker.com/engine/network/#dns-services), and +[container replacement](https://docs.docker.com/compose/how-tos/networking/#updating-containers-on-the-network). + diff --git a/docs/adr/0019-model-equations.md b/docs/adr/0019-model-equations.md index 22139b6..20d9d50 100644 --- a/docs/adr/0019-model-equations.md +++ b/docs/adr/0019-model-equations.md @@ -1,6 +1,6 @@ # ADR 0019: Use A Reduced-Order Mechanistic Wellhead Model -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 - Implementation: Implemented diff --git a/docs/adr/0020-model-tick-semantics.md b/docs/adr/0020-model-tick-semantics.md index 5724f5a..bcb23a6 100644 --- a/docs/adr/0020-model-tick-semantics.md +++ b/docs/adr/0020-model-tick-semantics.md @@ -1,8 +1,8 @@ # ADR 0020: Use Fixed Timestep Model Updates -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Implemented ## Context @@ -61,3 +61,56 @@ The service runs a one-second fixed scheduler with bounded catch-up, skipped-tic ## Implementation on 2026-09-29 The fixed scheduler now executes a pure one-second process step for each active well. Each skipped tick is counted per well and never replayed as a large step. Telemetry emission and durable snapshots remain later milestones. + +## Amendment 2026-10-03: Tick time follows the wall clock when it jumps + +### The problem + +The scheduler counts time with the monotonic clock, so a wall-clock change +cannot move its deadlines. Tick times were then worked out as the UTC time at +startup plus monotonic time elapsed since startup. On 2026-10-03 the host slept +for about 9 hours 15 minutes. The monotonic clock stood still for that time and +the wall clock did not. Every tick after waking carried a time 33,291 seconds +in the past. The Modbus gateway rejected every snapshot as stale, so no reading +was stored, while twin-core still reported ready, because its readiness check +used the same paused clock. + +### Decision + +Scheduling stays on the monotonic clock. Before each scheduler pass, +`twin_core/runtime.py` compares tick time with the wall clock using +`reconcile_clock` in `twin_core/scheduler.py`: + +- Within 2 seconds either way: ordinary jitter, nothing changes. +- Wall clock ahead by more than 2 seconds: the model did not run for that time. + Each whole second is counted as a skipped tick on every well, the + `well_sim_twin_clock_adjustments_total{direction="forward"}` metric goes up, + and tick time is moved onto the wall clock. The model then takes one ordinary + one-second step. It never takes one step the length of the missing time. +- Wall clock behind by more than 2 seconds: tick times must never go back, or + the model refuses the step. Ticks hold until the wall clock passes the last + tick. Holding is waiting, not missed time, so no gap is recorded. + +Readiness also compares the last tick with the wall clock. It reports +`tick_clock_drift` when they are more than 3 seconds apart, and +`wall_clock_moved_back` while ticks are held. + +**Do not stamp ticks from the monotonic clock alone again.** It cannot see a +machine that slept. + +### Consequences + +- A host that sleeps or has its clock corrected recovers without a restart, and + the missing time appears as a recorded gap instead of hidden stale times. +- Two extra clock reads per scheduler pass. +- A wall clock that is wrong and then corrected forward shows up as a gap even + though the model did run. That is the honest reading of what the wall clock + says, and it is visible in the metric and the log. + +## Implementation on 2026-10-13 + +The synthetic process model now supplies all 18 seeded Modbus signals. +Milestone 7 stores snapshots and recovery events. Milestone 8 uses the same +one-second step for saved forecasts and isolated scenarios. Model response, +recovery, and projection tests cover these paths. Field calibration remains +outside the implemented synthetic reference-well scope. diff --git a/docs/adr/0021-model-parameterization.md b/docs/adr/0021-model-parameterization.md index 13c8f19..6af4844 100644 --- a/docs/adr/0021-model-parameterization.md +++ b/docs/adr/0021-model-parameterization.md @@ -1,8 +1,8 @@ # ADR 0021: Use Version-Controlled Defaults With Database Overrides -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Complete for the current synthetic model ## Context @@ -87,3 +87,67 @@ Versioned defaults and validated global overrides load now. A typed per-well ove ## Implementation on 2026-09-29 The model now consumes validated typed parameters. Stable per-well variation is applied before global and future database overrides. Runtime parameter changes are rejected with `restart_required`. The database override table remains Milestone 7 work. + +## Amendment 2026-10-03: Overrides are edited by SQL until the control milestone + +### The problem + +Milestone 7 builds the per-well override table and its loader. Something has to +write the rows. An API in the TypeScript service would either copy the valid +ranges from `twin_core/model_defaults.json`, so the rule would live in two +places, or ask twin-core to validate through a new internal write endpoint. +That endpoint is the same kind of authenticated, audited write path the live +control API needs. + +### Decision + +- The table is `wellhead_model_parameter`, defined in the + [Milestone 7 design](../design/twin-core-milestone-7.md). Rows are only ever + added. A change is a new row; a row with `value` NULL returns the parameter + to its default. Each row records `source`, `changed_by`, and `reason`. +- An administrator edits it with SQL. There is no API or screen in Milestone 7. +- Twin-core loads the rows on every fleet refresh and validates them with the + existing resolver: known key, matching unit, named source, value in range. + An invalid value marks only that well unavailable. A changed value on a + running well marks it `restart_required`, so live behaviour never changes + silently. + +### Accepted risks + +- `changed_by` and `reason` are typed by hand. They are a record, not a + verified actor, so parameter changes are outside the audit trail of + [ADR 0034](0034-audit-trail-scope.md) until the control milestone builds an + authenticated write path. +- A wrong value is caught when twin-core loads it, not when it is written. The + well shows as unavailable instead of the write being refused. + +Calibration, the reason overrides exist, has no real well data to work from +yet, so overrides are expected to be rare until then. + +## Implementation on 2026-10-12 + +Migration 0010 adds the append-only `wellhead_model_parameter` table. Twin-core +loads the newest effective row for each well and key during fleet refresh. +A NULL value removes the per-well override and returns that key to the lower +precedence environment value or model default. +The existing resolver checks key, unit, source, and numeric range. Changed +parameters on a running well require a restart. SQL examples and the limits of +hand-entered actor names are in [the data guide](../../data/README.md). + +## Amendment 2026-10-13: Authenticated parameter publication replaces manual SQL + +Milestone 8B completes the write path deferred above. ADMIN reads the latest +override IDs through the API, creates an immutable proposal, then explicitly +publishes it. Twin-core validates the complete proposed parameter set with the +existing resolver. The API checks the current account and expected override ID, +then commits the new override, proposal result, and audit event together. + +The published row records the authenticated user ID and source authenticated_api. +Older manual records retain their original attribution. A NULL value still +removes an override. Publication reports that a restart is required and never +silently changes a running parameter set. The data guide now documents this API +workflow. Do not reintroduce manual SQL as the normal editing path. + +This adds proposal and audit records but keeps Python as the single authority +for parameter units, limits, and relationships. See +[ADR 0038](0038-live-control-command-lifecycle.md). diff --git a/docs/adr/0022-recovery-semantics.md b/docs/adr/0022-recovery-semantics.md index 26573cd..8a6d00c 100644 --- a/docs/adr/0022-recovery-semantics.md +++ b/docs/adr/0022-recovery-semantics.md @@ -1,8 +1,8 @@ # ADR 0022: Restore From Snapshots And Mark Recovery Gaps -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Complete for startup recovery and recorded clock jumps ## Context @@ -75,3 +75,65 @@ The scheduler bounds catch-up and records skipped time. Snapshot restoration, re ## Implementation on 2026-09-29 A new process run starts from a synthetic balance and is labelled `cold_start_no_snapshot`. Scheduler gaps increment a per-well counter without integrating missed time. Snapshot restoration and durable recovery events remain Milestone 7 work. + +## Implementation on 2026-10-03 + +A host that sleeps is now treated as downtime. Twin-core compares tick time +with the wall clock on every scheduler pass. When the wall clock has moved +ahead, the missing time is counted as skipped ticks per well and is not +replayed, as this record requires for longer gaps. See the ADR 0020 amendment +of the same date. + +## Amendment 2026-10-03: What state a well resumes from after a long gap + +### The problem + +The recovery flow above says to restore the latest snapshot, catch up small +gaps, and record larger ones. It does not say what the restored state should +be after a long gap. Resuming every field as saved would show pressures from +before the outage hours later. Starting from balance would throw away the +accumulated damage that snapshots exist to keep. + +### Decision + +- A gap of 5 seconds or less is caught up with real one-second steps. +- A longer gap is recorded as skipped ticks and a `snapshot_restored` recovery + event. Then: + - slow damage (water cut, corrosion, blockage) is restored exactly as saved, + and not advanced through the gap; + - the operating balance is re-solved with that damage and the current + controls; + - each fast state (tubing and casing pressure, wellhead and annulus + temperature, pump factor) moves from its saved value toward the balance by + $e^{-g/\tau}$, where $g$ is the gap and $\tau$ that state's time constant. + Tubing pressure's is about 6 minutes, and the others are under 4; + - annulus pressure follows the restored annulus temperature by the fixed-mass + gas law, and flow and the diagnostic readings are recomputed. +- A snapshot from another model version, or made under other parameters, is not + restored. The well cold-starts and a `snapshot_incompatible` event records why. + +The equations, time constants, and their check against the running model are in +the [Milestone 7 design](../design/twin-core-milestone-7.md). + +### Rejected + +- **Resume exactly as saved.** Simplest, but after hours offline the fast + states are stale values, not the state a real well would be in. +- **A fixed cut-off, such as 30 minutes.** A 25-minute gap would restore values + that would be 98% settled in reality, then jump at 30 minutes. +- **Always re-settle fast states.** A short restart would jump visibly to + balance. + +**Do not replay the gap or advance slow damage through it.** Both would present +model history that was never computed. + +## Implementation on 2026-10-12 + +Twin-core writes a fleet snapshot about every 30 model seconds and on clean +shutdown. At startup it checks each saved model version, parameter fingerprint, +timestamp, and process state before using it. It runs up to five real steps for +a short gap. For a longer gap it keeps saved slow damage and settles fast +states using the rule above. The well reports `recovering` until its first +new tick. Startup decisions and clock jumps enter `twin_recovery_event`. +The local Docker restart restored all 12 active wells; a real host sleep test +has not run. diff --git a/docs/adr/0024-twin-persistence-schema.md b/docs/adr/0024-twin-persistence-schema.md index 914e6e5..9cb3707 100644 --- a/docs/adr/0024-twin-persistence-schema.md +++ b/docs/adr/0024-twin-persistence-schema.md @@ -1,8 +1,8 @@ # ADR 0024: Use Relational Twin Metadata With Time-Series Run Outputs -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Not started +- Implementation: Milestones 7, 8, and 8B tables implemented ## Context @@ -196,3 +196,62 @@ Suggested fields: - JSONB payloads should not replace high-value query columns. - Retention and compression policies still need separate decisions. + +## Amendment 2026-10-03: Milestone 7 creates only the tables it writes + +### The problem + +This record lists nine tables. In Milestone 7 only three would be written: the +state snapshots, the recovery events, and the per-well parameter overrides. The +prediction and scenario tables would stay empty until Milestone 8. The control +table would stay empty with no milestone planned to write it, because no +milestone built the control API. Phase 3.9 of the repair work removed database +objects that nothing used, for this reason. + +### Decision + +- Milestone 7 creates `twin_state_snapshot`, `twin_recovery_event`, and + `wellhead_model_parameter`. Their columns are in the + [Milestone 7 design](../design/twin-core-milestone-7.md). +- `prediction_run`, `prediction_point`, `scenario_run`, and `scenario_point` + arrive in Milestone 8, with the code that writes them. +- `control_command` arrives with the live control API, in a new milestone after + Milestone 8. +- `twin_model_definition` and `wellhead_model_config` are not created while the + code runs one model version. Snapshots, recovery events, overrides, and + readings each record their model version as text, which a definition table + can join to later. +- The snapshot table names its columns with units, such as + `tubing_pressure_psi`, following the reading and model payload conventions, + rather than the bare names suggested above. + +The suggested tables above remain the intended shape for later milestones. + +## Implementation on 2026-10-12 + +Migration 0010 creates only the three Milestone 7 tables listed in the +amendment. Snapshots and recovery events are TimescaleDB hypertables. The +snapshot stores queryable pressure, flow, temperature, damage, and status +columns next to the complete JSONB process state. The Drizzle schema mirrors +these three tables. Forecast, scenario, and control tables remain for their +own milestones. + +## Implementation on 2026-10-13 + +Migration 0011 creates `prediction_run`, `prediction_point`, `scenario_run`, and +`scenario_point` with the code that writes them. Runs are ordinary tables with +actor, model version, parameter fingerprint, base state, inputs, alarm rules, +and confidence. Points are TimescaleDB hypertables. Their primary key includes +the time column. Full model values remain in JSONB; tubing pressure, flow rate, +wellhead temperature, water cut, alarm risk, and sample time are separate +queryable columns. Scenario points also hold unchanged baseline values. The +`control_command` table still belongs to the live control milestone. + +## Implementation on 2026-10-13: Live controls and parameter publication + +Migration 0012 creates control_command, wellhead_control_state, +control_command_event, and model_parameter_change. The Drizzle schema mirrors +all four. Immutable proposals, lifecycle checks, unique pending commands, and +separate service grants protect the write path. Parameter publication appends +to the existing wellhead_model_parameter history. See +[ADR 0038](0038-live-control-command-lifecycle.md) for the implemented lifecycle. diff --git a/docs/adr/0025-forecast-horizon-confidence.md b/docs/adr/0025-forecast-horizon-confidence.md index d5ed978..27c0465 100644 --- a/docs/adr/0025-forecast-horizon-confidence.md +++ b/docs/adr/0025-forecast-horizon-confidence.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-05-31 -- Implementation: Not started +- Implementation: Implemented as an uncalibrated run-level heuristic ## Context @@ -81,3 +81,17 @@ At the forecast-point level: - The dashboard should label heuristic confidence clearly. - Historical forecast error tracking can later support calibrated confidence intervals. +## Implementation on 2026-10-13 + +Milestone 8 implements the defaults and limits above, with a 300-second minimum +horizon. It uses the existing one-second model step and samples output at the +requested interval. The run-level score starts at 0.65, falls by up to 0.25 +with horizon, and deducts 0.10 each for recent recovery, being near a rule +threshold or having no active rules, and a sharp control change. It is clamped +to 0.10 through 0.65. Every response labels the method +`heuristic_uncalibrated` and `fieldCalibrated: false`. A rule crossing is a +categorical risk label, not a probability. Model status must be running and +fresh before a run starts; non-running states are refused instead of being +assigned a lower score. The exact formula and units are in the +[Milestone 8 design](../design/twin-core-milestone-8.md). + diff --git a/docs/adr/0026-retention-and-compression.md b/docs/adr/0026-retention-and-compression.md index 51a7ffd..b65cbd2 100644 --- a/docs/adr/0026-retention-and-compression.md +++ b/docs/adr/0026-retention-and-compression.md @@ -1,8 +1,12 @@ # ADR 0026: Use Tiered Retention And TimescaleDB Compression -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Implemented +- Implementation: Installed local policies recorded in the 2026-10-17 amendment + +The original policy table below records the proposed targets. Use the +[current policy amendment](#amendment-2026-10-17-record-the-installed-retention-policy) +for this deployment's actual retention and compression. ## Context @@ -62,3 +66,77 @@ Retention should be configurable by deployment environment: - Operators should understand that forecast points are not permanent historical truth. - Retention policies must be documented in deployment docs. +## Implementation on 2026-10-12 + +Migration 0010 gives `twin_state_snapshot` a 90-day retention policy and +compression after 7 days. `twin_recovery_event` has a 2-year retention policy. +The prediction, scenario, and control rows in the table above remain plans for +later milestones. + +## Implementation on 2026-10-13 + +Migration 0011 compresses prediction points after 3 days and retains them for +30 days. Scenario points are compressed after 7 days and retained for 90 days. +Run metadata remains in ordinary tables and outlives its points; the API +returns HTTP 410 when any saved point has expired. The control-command policy +is still planned for Milestone 8B. + +## Amendment 2026-10-17: Record the installed retention policy + +### Context and decision + +The original targets above do not describe the local schema. Milestone 8B also +deliberately retained command history without automatic expiry. Scheduled jobs +and alarm actions added more durable ordinary tables. This amendment records +the implemented policy from `data/sql/schema.sql`; it changes no database policy. + +| Stored data | Automatic retention | Compression after | +| --- | --- | --- | +| `parameterreading` | 7 days | 1 day | +| `alarmevent` | 90 days | 7 days | +| `auditevent` | 90 days | None configured | +| `twin_state_snapshot` | 90 days | 7 days | +| `twin_recovery_event` | 2 years | None configured | +| `prediction_point` | 30 days | 3 days | +| `scenario_point` | 90 days | 7 days | +| `prediction_run`, `scenario_run` | No automatic expiry | Not configured | +| `control_command`, `control_command_event`, `model_parameter_change` | No automatic expiry | Not configured | +| `wellhead_control_state`, `wellhead_model_parameter` | No automatic expiry | Not configured | +| `forecast_schedule`, `forecast_job` | No automatic expiry | Not configured | +| `alarm_action`, `alarm_acknowledgement`, `alarm_shelf` | No automatic expiry | Not configured | + +Retention policies remove eligible time chunks when their background jobs run; +these ages are not exact per-row deletion deadlines. Projection point retention +uses simulated time. A newly calculated historical scenario can therefore lose +points sooner than one starting now. The API returns 410 when a saved run no +longer has its complete point set, while run metadata and job receipts remain. + +Continuous aggregates have refresh policies, but no separate automatic retention +policy. A refresh window is not a retention guarantee. Metadata tables also have +no age-based expiry. Explicit asset deletion and foreign-key actions are separate +from the time-based policies in this table. + +There is no environment-variable interface for changing these policies. A future +policy change must update the canonical SQL and provide a reviewed conversion for +existing installations. Do not reintroduce numbered schema files. + +### Consequences + +**Good.** Short demo retention bounds the largest histories. Durable receipts keep +the meaning of reviewed operating actions after general audit rows expire. + +**Costs.** Ordinary history and aggregate storage can grow. A future archive +policy must account for references between commands, results and receipts. + +**Accepted risks.** This local deployment has no automatic archive for those +records. Do not infer three-year command expiry or permanent alarm-event storage +from the original targets. A longer-lived installation needs an explicit archive +and capacity plan. + +### Related + +- [ADR 0038](0038-live-control-command-lifecycle.md): durable control history. +- [ADR 0039](0039-alarm-operator-actions.md): acknowledgement survives event retention. +- [Scheduled forecasts](../design/scheduled-forecasts.md): job records and expiring points. +- [Database updates](../operations/database-updates.md): verified changes to an existing installation. + diff --git a/docs/adr/0027-historical-branching.md b/docs/adr/0027-historical-branching.md index df91108..8e5fe69 100644 --- a/docs/adr/0027-historical-branching.md +++ b/docs/adr/0027-historical-branching.md @@ -1,8 +1,8 @@ # ADR 0027: Stage Historical Scenario Branching From Model Snapshots -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Not started +- Implementation: Historical scenarios, 2026-10-17 ## Context @@ -58,3 +58,32 @@ The UI and API must clearly show whether a scenario starts from current state or - Historical records must never be mutated by scenario execution. - Incident-review workflows can be added after current-state scenarios are stable. +## Amendment on 2026-10-12 + +The planned `model_state_snapshot` table was built in Milestone 7 as +`twin_state_snapshot`. Version 2 historical branching should read that table. +It must not create a second snapshot table for the same model state. Version 1 +still starts from current live state, and historical branching remains future +work. + + +## Amendment on 2026-10-17 + +Historical scenarios now select an exact `twin_state_snapshot` by its bigint +identifier and simulated timestamp. Requests preserve identifiers as strings and +timestamp precision through microseconds. The installed model version and +resolved parameter fingerprint must match the selected snapshot. Snapshots lack +full parameter values, so incompatible snapshots cannot run under a new set. + +The existing `scenario_run.base_state` JSON stores `branchOrigin`, including the +source identifier and time. Together with `base_simulated_at`, this fulfills the +provenance requirement above without adding separate columns. The run already +stores its full state, parameters, controls and alarm rules. Legacy runs without +origin metadata start from current state. No numbered migration or SQL change +is required. + +The finder searches bounded 24-hour windows within the preceding 89 days. +Historical chart points follow the existing retention policy on simulated time, +so new historical runs can expire sooner than current-state runs. The UI states +this limit and identifies the alarm rules as those active at execution. +See the [design and verification](../design/historical-scenarios.md). diff --git a/docs/adr/0029-metrics-stack.md b/docs/adr/0029-metrics-stack.md index ac84564..5e0a2d6 100644 --- a/docs/adr/0029-metrics-stack.md +++ b/docs/adr/0029-metrics-stack.md @@ -1,8 +1,8 @@ # ADR 0029: Expose Prometheus-Compatible Metrics With Optional Observability Stack -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 -- Implementation: Partial +- Implementation: Implemented ## Context @@ -90,3 +90,20 @@ docker compose --profile observability up - Dashboards can be added after core service metrics stabilize. - Default local setup remains lightweight. + +## Amendment: optional stack delivered on 2026-10-17 + +Add Prometheus and Grafana through the `observability` Compose profile, with +pinned official images, separate persistent volumes and loopback host ports. +Provision one Service health dashboard from versioned files. Keep the normal +well stack independent of these tools and their credentials. + +Keep the existing `api_` and `wellhead_` metrics as published interfaces. New +readiness and timestamp metrics use `well_sim_`; recording rules join them into +consistent service status and elapsed-time queries. A successful scrape alone +does not establish readiness. Missing, stale or future observations stay unknown. + +Grafana has a separate local administrator password, initialized from the ignored +environment file. It cannot change well controls or alarm state. The design, +limits and verification are in [Optional service metrics](../design/observability.md). +Startup and recovery instructions are in the [metrics runbook](../../observability/README.md). diff --git a/docs/adr/0031-portfolio-demo-narrative.md b/docs/adr/0031-portfolio-demo-narrative.md index 8ed7c66..7575438 100644 --- a/docs/adr/0031-portfolio-demo-narrative.md +++ b/docs/adr/0031-portfolio-demo-narrative.md @@ -1,6 +1,6 @@ # ADR 0031: Optimize The Demo For Fast Reviewer Understanding -- Status: Accepted +- Status: Accepted, amended - Date: 2026-05-31 - Implementation: Partial @@ -61,3 +61,86 @@ The primary demo should show: - Scenario capability should be implemented early because it demonstrates digital twin behavior. - Screenshots, diagrams, and demo scripts should be kept current. +## Amendment 2026-10-16: Keep monitoring and analysis in one wellhead workspace + +### Context + +Milestones 4 through 8B now provide the running model, historian, saved +projections, and live control APIs. The browser previously showed only monitoring. +A reviewer needed to construct API requests to see a scenario. + +### Options considered + +- Keep API examples as the main demo. This gives precise requests but asks a + reviewer to assemble the comparison themselves. +- Add independent analysis pages. Each page would be simpler, but moving between + monitoring and a scenario could lose the selected well's context. +- Add Monitoring, Forecast, Scenarios, and Saved runs within each wellhead + workspace. This keeps the selected well visible and reuses the existing model. + This option is chosen. + +### Decision + +Use a compact fleet table and a shared wellhead header. Keep the existing light +theme, blue actions, white panels, and purple analysis line. Distinguish baseline +and scenario with labels and line patterns as well as colour. Stack the form and +chart on small screens and provide the plotted values in a table. + +Use existing React Router loaders and form actions, shared request validation, +and saved projection tables. The public API adds status, bounded history, and +the original inputs and signal units on saved reads. Do not introduce a second +data cache or another schema file for these screens. + +Scenario drafts survive status refreshes. Creation uses one explicit POST with +no automatic retry. A lost response directs the user to saved history because +the server may already have completed the run. History is shared with authorized +dashboard readers; model-copy isolation does not imply owner-only visibility. + +The installed-control panel is read-only. Live command forms remain separate +future work, following ADR 0007 and the authority rules in ADR 0038. + +### Consequences + +**Good.** A reviewer can monitor, compare, and reopen results in the browser. +Saved inputs and explicit model times make each chart easier to interpret. + +**Costs.** Browser verification must cover mobile layouts, session failures, +stale data, invalid forms, expired results, and uncertain submission outcomes. +Screenshots and the demo script must follow the working application. + +**Accepted risks.** History can contain results whose points have expired. +Confidence remains heuristic, and threshold summaries can miss events between +returned samples. The UI explains these limits beside the results. + +### Related + +- [Milestone 9 design](../design/twin-core-milestone-9.md) records screens, time semantics, and verification. +- [ADR 0007](0007-control-and-what-if-boundaries.md) defines the separation from live control. +- [ADR 0025](0025-forecast-horizon-confidence.md) defines the confidence limitations. + + +## Amendment: explain the dashboard workflow (2026-10-17) + +### Context + +The owner found the dashboard confusing after the initial browser checks passed. +The pages exposed valid data but assumed knowledge of drafts, baseline controls, +model clocks and saved calculations. Technical checks had not established usability. + +### Decision + +Lead each page with its task. Add an optional Guide and contextual instructions. +Keep the running well, a new draft and a saved result distinct. Put diagnostics +in an expandable section, retain visible data age, and explain result limitations +beside the chart. Use the same panels, headings and recovery patterns across pages. +Replace the login photograph with an original text layout and labelled fields. + +The owner requested two design checks before implementation. The +[usability design](../design/dashboard-usability.md) records both checks and the +rendered gallery. Later control and analysis features follow the same sequence. + +### Consequences + +Users can learn the available workflow without an automatic tour. Help needs to +change when a feature changes. Login, Guide, Alarms and Analytics join the +browser checks. Automated accessibility results still need human usability review. diff --git a/docs/adr/0032-alarm-center-scope.md b/docs/adr/0032-alarm-center-scope.md index 785e37b..2ae93f5 100644 --- a/docs/adr/0032-alarm-center-scope.md +++ b/docs/adr/0032-alarm-center-scope.md @@ -1,6 +1,6 @@ # ADR 0032: Implement Active Alarm Inspection Before Alarm Lifecycle Workflows -- Status: Accepted +- Status: Accepted, amended - Date: 2026-06-21 - Implementation: Implemented @@ -56,3 +56,15 @@ This keeps the project honest. It improves dashboard credibility using real exis - The API may need a richer alarm DTO for threshold and asset context. - Future alarm lifecycle work should get its own ADR before implementation. - The demo can say: active alarm inspection is implemented; operator alarm management is planned. + +## Amendment, 2026-10-17: add recorded operator actions + +The owner approved acknowledgement and timed shelving after the inspection +workflow. [ADR 0039](0039-alarm-operator-actions.md) adds explicit persistence, +current-role checks, exact event identity, reviewed writes and atomic audit +receipts. The Alarm Center now exposes those actions to OPERATIONS. ADMIN does +not inherit operating authority. Active totals still reflect process conditions. + +This amendment completes the prerequisite in the original decision. General +suppression, rule editing and a site alarm-management program remain outside +this workflow. The earlier inspection-only decision remains above as history. diff --git a/docs/adr/0033-role-model-and-access-control.md b/docs/adr/0033-role-model-and-access-control.md index f353924..6630b51 100644 --- a/docs/adr/0033-role-model-and-access-control.md +++ b/docs/adr/0033-role-model-and-access-control.md @@ -137,3 +137,11 @@ to revisit. subset rather than granted globally. - A regulatory obligation requiring formal approval workflows, where issuing a control command needs a second actor rather than a single role check. + +## Implementation update, 2026-10-17 + +OPERATIONS now has both reviewed live synthetic control screens and alarm +acknowledgement/shelving. ADMIN retains parameter publication and user creation, +without operating authority. Each accepted alarm write rechecks the current +account inside its transaction, including session revocation, and records an +atomic action receipt and audit event. See [ADR 0039](0039-alarm-operator-actions.md). diff --git a/docs/adr/0034-audit-trail-scope.md b/docs/adr/0034-audit-trail-scope.md index 1a7aee7..e7019c4 100644 --- a/docs/adr/0034-audit-trail-scope.md +++ b/docs/adr/0034-audit-trail-scope.md @@ -1,6 +1,6 @@ # ADR 0034: Record An Audit Trail For Actors, Not For Auditors -- Status: Accepted +- Status: Accepted, amended - Date: 2026-08-29 - Implementation: Implemented @@ -153,3 +153,62 @@ has been kept. - The control API ships. The promotion workflow should be reconsidered then. - Audit volume stops being bounded by the rate limiter, for example if a machine client is given credentials. + +## Amendment 2026-10-03: Name the rate-limited routes exactly + +### The problem + +This record says "`/auth/*` is capped at 10 a minute per client". The limiter +did match every path under `/auth/`, and that included `/auth/me`, the session +check the dashboard makes. Every timed refresh repeated that check, so one open +tab spent 6 of the 10 requests a minute, and a second tab locked its user out. + +### Decision + +The strict budget of 10 a minute per client now covers three named routes: +`/auth/login`, `/auth/register`, and `/auth/logout`. `/auth/me` moves to the +general budget of 100 a minute. It checks no password and writes no audit row, +so the bound this record relies on is unchanged. + +Logout stays on the strict budget on purpose. It writes an `auth.logout` row, +and it only deletes the cookie without revoking the session, so a copied +cookie could replay it. Moving it to the general budget would let one client +write 100 audit rows a minute. **Do not move `/auth/logout` off the strict +budget** unless logout starts revoking the session. + +The list lives in `CREDENTIAL_PATHS` in +`monorepo/apps/api/src/middleware/rate-limiter.ts`. A new route that checks a +password or writes an audit row must be added there. + +## Amendment 2026-10-03 (later): Logout now revokes the session + +[ADR 0037](0037-session-revocation.md) makes logout end every session of the +user on the server. A copied cookie can no longer replay logout: the second +call is refused before any audit row is written. The reason given above for +keeping `/auth/logout` on the strict budget no longer applies, but the budget +stays as it is. Ten logouts a minute is far above real use, and the limit is a +second line of defence if revocation ever regresses. + +## Amendment 2026-10-13: Control and parameter writes require atomic audit + +A live control or parameter publication must not succeed without its actor +record. Milestone 8B therefore inserts audit rows in the same transaction as +accepted draft, activation, cancellation, and publication changes. A failed +audit insert rolls back the operation. The existing best-effort helper remains +appropriate for authentication events, where an audit outage must not silently +change the login result. + +Twin-core records application, rejection, and expiry in control_command_event +in the same transaction as the command result. The command retains its human +activation identity. This costs a database dependency for new control changes, +but allows every accepted change to be traced after a restart. See +[ADR 0038](0038-live-control-command-lifecycle.md). + +## Amendment, 2026-10-17: alarm actions require atomic audit + +Acknowledgement, shelving and early unshelving follow the same atomic-write rule +as live controls. The receipt, lifecycle state and audit event commit together. +The shared ten-per-user-per-minute command budget bounds these writes. A replay +returns the existing receipt without adding another audit event. General audit +retention remains 90 days; alarm action receipts remain durable alongside their +current-state references. See [ADR 0039](0039-alarm-operator-actions.md). diff --git a/docs/adr/0035-metadata-reload.md b/docs/adr/0035-metadata-reload.md index 6c6f230..113149a 100644 --- a/docs/adr/0035-metadata-reload.md +++ b/docs/adr/0035-metadata-reload.md @@ -1,6 +1,6 @@ # ADR 0035: How Metadata Changes Reach A Running Service -- Status: Accepted +- Status: Accepted, amended - Date: 2026-08-29 - Implementation: Implemented @@ -153,3 +153,42 @@ That balance changes when the control API ships. At that point a config change becomes an operator action that should take effect when the operator says so, not up to an interval later, and it needs the audit trail from ADR 0034 around it. Revisit Option D then. + +## Amendment 2026-10-17: Separate metadata refresh from operating changes + +### Current behavior + +The model replaced the old random simulator. Gateway and ingestion still reload +mappings each telemetry interval, five seconds by default. Twin-core refreshes +active wells and parameter overrides every 30 seconds by default, configured by +`TWIN_FLEET_REFRESH_SECONDS`. Alarm evaluation reads the current rules when a +reading batch arrives; it does not wait for a separate rule-reload timer. + +A valid parameter publication does not silently alter a running model. Fleet +refresh detects the changed parameters and marks that well `restart_required`. +A service restart adopts them. Invalid parameters make that well unavailable. + +Live controls now use the durable command queue in ADR 0038. An authenticated +HTTP hint can wake the worker, and queue polling recovers a missed hint. A +general metadata reload endpoint is not the control application mechanism. + +### Decision and consequences + +Keep timed metadata refresh, reviewed parameter publication and durable command +application as separate paths. A `config.change` audit record establishes a +stored change, not proof that every service has adopted it. Read the running +revision, parameter fingerprint and model status to establish adoption. + +**Good.** Operators can distinguish publication, pending restart and application. + +**Costs.** The UI must expose both stored and running state, and metadata has a +bounded delay during healthy operation. Dependency failures can extend that delay. + +**Accepted risks.** Parameter publication requires a coordinated restart. This +local workflow does not offer uninterrupted model reconfiguration. + +### Related + +- [ADR 0021](0021-model-parameterization.md): override resolution and restart adoption. +- [ADR 0038](0038-live-control-command-lifecycle.md): durable control application. +- [Operating screens](../design/operating-workspace.md): review and outcome recovery. diff --git a/docs/adr/0036-model-telemetry-through-modbus.md b/docs/adr/0036-model-telemetry-through-modbus.md index a1519cd..e4b1bc8 100644 --- a/docs/adr/0036-model-telemetry-through-modbus.md +++ b/docs/adr/0036-model-telemetry-through-modbus.md @@ -2,11 +2,13 @@ **Status**: Accepted, amended +**Implementation**: Implemented, including all 18 model signals and model version metadata. + **Date**: 2026-09-30 **Decision**: The gateway reads one complete synthetic model fleet snapshot from twin-core and maps only values the model calculates into Modbus holding registers. Ingestion keeps polling Modbus and stores complete batches with their model tick time and source label. This preserves the industrial read path without presenting unsupported or stale values as fresh measurements. -Roadmap: [Milestone 6](../roadmap.md#milestone-6-integrate-modbus-with-twin-core) +Design: [Milestone 6](../design/twin-core-milestone-6.md) --- @@ -154,3 +156,101 @@ and Faraday-law calculation described in the The outputs are synthetic estimates, not field measurements. Model source labelling and `fieldCalibrated: false` remain required. Old random readings keep their original source until retention removes them. + +## Amendment 2026-10-02: Publish the model version with the heartbeat + +### The problem + +A stored reading recorded its tick time and its source kind, but not which +model version calculated it. Twin-core already reports `modelVersion` in every +fleet snapshot. After a model change, the historian could not tell readings +from version 0.3.0 apart from readings made by the next version. The +[reference-well design](../design/reference-well-physics.md) listed this as a +gap for Milestone 7. + +### Options considered + +**Carry the version through Modbus (chosen).** The gateway already publishes a +status value (the heartbeat) that ingestion checks before and after every +poll. The version can travel in the same block. This keeps Modbus as the only +path from the model to the historian. It costs three registers, a migration, +and a register-map change that both services must follow. + +**Ingestion asks twin-core over HTTP.** This needs no register change. But it +gives ingestion a second source of truth beside Modbus. While twin-core +restarts, the HTTP answer and the registers can describe different models, +and ingestion would gain a new dependency on twin-core. + +**Leave it for Milestone 7.** [ADR 0024](0024-twin-persistence-schema.md) +plans model definition tables with a version. Linking readings to them there +would design the link once. Every reading stored until then would carry no +version, so the gap would keep growing. + +### Decision + +**Registers.** The heartbeat and the version form one status block of five +holding registers on every mapped unit: + +| Offset | Content | Encoding | +| --- | --- | --- | +| 1900 to 1901 | Model tick, UTC epoch seconds | unsigned 32 bit, shared `WORD_ORDER` | +| 1902 | Major version | unsigned 16 bit | +| 1903 | Minor version | unsigned 16 bit | +| 1904 | Patch version | unsigned 16 bit | + +Version text such as `0.3.0` becomes the registers `0, 3, 0`. An all-zero +version means "not published", just as a zero heartbeat does, so version +`0.0.0` is reserved and rejected. Each part must fit in 16 bits. + +**Logic.** `telemetry_common.ModelVersion` is the one definition of the text +and register forms. The gateway parses `modelVersion` from the snapshot and +rejects a batch whose version cannot be encoded. It zeroes the whole status +block before changing any value, then writes the heartbeat and version in one +five-register write after all values are in place. Ingestion reads the block +with one Read Holding Registers request (function code 03, offset 1900, +quantity 5), so both parts come from one moment on the server. Every unit must +report the same tick and the same version, before and after the poll. A +heartbeat with a zero or malformed version rejects the batch, and a version +that changes between the two reads discards it. + +**Schema.** Migration `0008_reading_model_version.sql` adds +`parameterReading.model_version` with the domain type `model_version_text`. A +domain is a named type that carries its own check; this one accepts only +`major.minor.patch` digits. Rows written before the migration keep `NULL`, +meaning "not recorded". Their version cannot be recovered, so it is not +backfilled. + +### Trap: do not turn the domain into a table constraint + +TimescaleDB 2.19 refuses to add any table CHECK constraint, even one marked +`NOT VALID`, once a hypertable holds compressed chunks. `parameterReading` +compresses readings older than one day (migration 0005), so a constraint +migration fails on any volume that has run for more than a day. This was tested +on 2026-10-02. Adding a column of a domain type is allowed and still rejects a +malformed value on insert. **Do not reintroduce a table constraint for this +column.** For the same reason, the rule "random simulator rows have no +version" is not enforced in the database. It spans two columns, which a domain +cannot check. Ingestion is the only writer and sets the version on every model +row. + +### Consequences + +**Good.** + +- Every new reading names the model version that produced it. +- Milestone 7 can join `model_version` to the version in + `twin_model_definition` without changing the reading format again. + +**Costs.** + +- The status read grows from two registers to five per unit, still one request. +- The register map gains a reserved range (1900 to 1904) that metadata must + never map. The existing mapping check already rejects signal registers at + 1899 and above. + +**Accepted risks.** + +- Readings stored before 2026-10-02 have no recorded version. Retention removes + them within seven days. +- A twin-core redeploy discards the batch in progress. Ingestion resumes at the + next tick under the new version, leaving a visible gap. diff --git a/docs/adr/0037-session-revocation.md b/docs/adr/0037-session-revocation.md new file mode 100644 index 0000000..785e25b --- /dev/null +++ b/docs/adr/0037-session-revocation.md @@ -0,0 +1,139 @@ +# ADR-0037: Logout ends every session of the user, on the server + +**Status**: Accepted + +**Implementation**: Implemented. Logout increments the session version; authenticated requests check it. + +**Date**: 2026-10-03 + +**Decision**: Each user has a session version number. Every session token carries the number it was issued with, and the API refuses a token whose number no longer matches the user's. Logout adds one to the number, so every token issued until then stops working on every device, including a copied cookie. The check costs one primary-key read of the `users` table per authenticated request. + +--- + +## Context + +Sessions are signed tokens (JWTs) in an `auth_token` cookie, valid for 24 +hours. The API checked only the signature and the expiry time. Nothing on the +server recorded that a session had ended. + +Logout deleted the cookie in the browser that asked. A copy of the token, taken +from a shared machine, a proxy log, or an exported browser profile, kept working +for the rest of its 24 hours. The remediation plan (Phase 4.5) listed +revocation, and the authorization middleware said it was "tracked separately", +but it was never built. + +It also mattered for auditing. Logout writes an `auth.logout` row +([ADR 0034](0034-audit-trail-scope.md)), and a token that survived logout could +replay that call and keep writing rows. + +### What already exists + +- `monorepo/apps/api/src/routers/auth.ts` issues the token (`issueSession`) and + handles logout. +- `monorepo/apps/api/src/middleware/auth.ts` verifies the token on every + request. +- The `users` table, created by migration `0001_core_schema.sql`. + +--- + +## Options considered + +### A. A session version per user (chosen) + +A `session_version` number on each user, copied into the token as `sv`. The +middleware compares the two on each request, and logout increments the number. + +It needs one integer column and one indexed read per request, and nothing has +to be cleaned up later. The cost is that logout ends all of a user's sessions, +not only the one that asked. + +### B. A list of revoked tokens + +Give each token an id. Logout stores the id until the token would have expired, +and every request checks the list. + +This ends exactly one session, which is more precise. But it needs a table that +grows with every logout and a job to delete expired rows, and it still needs a +database read per request. For a dashboard where one person rarely holds many +sessions, the precision does not pay for the extra moving parts. + +### C. A shorter token with renewal + +Cut the token to about an hour and renew it while the user is active. There is +no database read at all. + +It shrinks the window but does not close it: a copied cookie still works until +it expires, and logout is still not revocation. It also needs renewal logic in +the dashboard. + +--- + +## Decision + +### Schema + +Migration `0009_user_session_version.sql` adds +`users.session_version INTEGER NOT NULL DEFAULT 0`. The Drizzle mirror in +`packages/db/schemas/user.ts` declares it as `sessionVersion`. + +### Logic + +- `issueSession` puts the user's current `session_version` into the token as + the `sv` claim. +- `authMiddleware` verifies the signature, then reads the user's + `session_version` by primary key. The request is refused with 401, and the + cookie cleared, when the token has no `sv`, when `sv` does not match, or when + the user no longer exists. +- If the database cannot be read, the request fails. Letting a session through + without knowing whether it was revoked would defeat the check. +- Logout increments `session_version`, then clears the cookie and writes the + audit row. +- Signing in does not change the number. Signing in on a second device must not + sign the user out of the first. +- The number is internal and is removed from user objects in API responses, + alongside the password hash. + +### API surface + +`POST /auth/logout` keeps its shape. Its contract now says it ends every session +and requires one. The contract previously marked it as public, which it had +not been since Phase 4. + +### Migration + +Tokens issued before this change carry no `sv`, so they are refused. Every user +signs in once more after the upgrade. No data is lost. + +--- + +## Consequences + +**Good.** + +- A copied cookie stops working the moment its user logs out. +- A replayed logout can no longer keep writing audit rows, which strengthens the + bound ADR 0034 relies on. +- The same read could later supply the user's current role. Today the role still + comes from the token, so a role change waits for the next sign-in. + +**Costs.** + +- One extra database read on every authenticated request. +- Logging out on one device signs the user out everywhere. + +**Accepted risks.** + +- A token copied while the user stays signed in still works until it expires + or the user logs out. Ending a single session without logout needs option B + or an administrator action, which does not exist yet. +- When the database is down, authenticated requests fail. They already did, + because every dashboard route reads the database. + +--- + +## Related + +- [ADR 0033](0033-role-model-and-access-control.md): the roles carried in the + same token. Their check runs after this one. +- [ADR 0034](0034-audit-trail-scope.md): the audit rows that logout writes, and + the rate limit that bounds them. diff --git a/docs/adr/0038-live-control-command-lifecycle.md b/docs/adr/0038-live-control-command-lifecycle.md new file mode 100644 index 0000000..983dafc --- /dev/null +++ b/docs/adr/0038-live-control-command-lifecycle.md @@ -0,0 +1,131 @@ +# ADR-0038: Apply live model controls through durable, reviewed commands + +**Status**: Accepted, amended +**Implementation**: Built; verification results are recorded in the Milestone 8B design. +**Date**: 2026-10-13 +**Decision**: An operator creates a draft command, then explicitly activates it. Activation records the command and its audit event before twin-core applies the target controls at a model tick. A command expires after 30 seconds if it cannot be applied. The active control row has a revision, so a stale command cannot replace newer controls. This changes only the synthetic model, never a physical actuator. + +Roadmap: Milestone 8B in the local feature roadmap. + +--- + +## Context + +Twin-core holds each well's controls in memory. A restart recovers them from a model snapshot. Forecasts and scenarios copy those controls but never change the running well. Milestone 8B needs a separate path for a deliberate live change. + +A direct HTTP request that changes memory first can be lost on a crash. A database row alone cannot tell the caller when the running model applied it. The existing audit helper may swallow a failed write, which is acceptable for a login but not for a control change. The public API also uses the database administrator account in Compose today. That credential must be reduced before control commands are enabled. + +This project has no physical control connection. The Modbus gateway publishes model telemetry; it does not accept control writes. The emergency shutdown field is a synthetic model input, not a safety instrumented function. + +## Options considered + +### A. Apply through HTTP, then save + +The API could send controls directly to twin-core and save the result afterwards. It is small and fast. A crash between the memory change and database write would leave an unattributed or unrepeatable change. + +### B. Save a command, then apply at a tick (chosen) + +The API saves an activation request and audit row together. Twin-core receives an HTTP wake-up and also scans for queued commands after a missed wake-up or restart. It applies only a current command at a tick. This adds a small queue and a recovery path, but keeps the user's accepted request durable. + +### C. Use the database as the only command channel + +Twin-core could poll command rows without an HTTP wake-up. This is simple to deploy and survives restarts. It gives normal command latency to the poll interval and turns the database into the sole service interaction path, contrary to ADR 0011. + +## Decision + +### Command states and data + +Migration 0012 adds three control tables and the separate model_parameter_change +table. These are ordinary PostgreSQL records rather than telemetry hypertables. + +- control_command stores one immutable proposal: command UUID, well ID, creator, reason, five optional control targets, expected control revision, model version, parameter fingerprint, creation time, and idempotency key. Its status moves through draft, queued, then applied, rejected, expired, or cancelled. It also records activation and result times. +- wellhead_control_state stores one current row per well: a nonnegative revision, all six model control values, the current model version and parameter fingerprint, the applied command UUID, and the time they became active. The swab valve remains a stored model value but is not an editable command target. +- control_command_event is append-only. It records each state change, its time, command ID, actor when human, and a bounded reason code. Command and event rows have no automatic retention in this local portfolio deployment. + +Database checks enforce choke position from 0 to 100 percent, at least one proposed field, and valid statuses. Unique indexes allow one draft per creator and well, and one queued command per well. The API can update only command lifecycle fields. Twin-core can read queued commands and update only their result fields and the active control row. Neither service can delete command history. The migrator keeps schema ownership. + +Drafts do nothing to the model. A changed draft is a new command; the old one is cancelled. Only its creator may activate or cancel a draft. That operator performs both steps. This is explicit confirmation, not two-person approval. A draft older than 24 hours expires without affecting the model. + +### Authorisation and activation + +The public API requires the existing control:issue capability for draft, activate, cancel, and detailed status routes. It also locks and reads the user's current role and status from the database inside each write transaction. Role changes and command writes therefore have a definite order. A signed token carrying an old OPERATIONS role cannot activate after the account is disabled or downgraded. ADMIN does not inherit control authority. + +Activation first checks that twin-core has a current running model. It then locks the command and active control row. It requires a draft, the expected revision, the same model version and parameter fingerprint, and no other queued command for that well. It records the expected revision, sets a database-clock expiry 30 seconds away, and inserts an audit_event row in the same transaction. If that transaction fails, no command is queued. The existing best-effort audit helper is not used for a command write. + +The API returns queued, never applied, after a confirmed commit. It sends the command UUID over authenticated internal HTTP as a wake-up hint. If the hint fails, a bounded background scan finds the committed row. The command UUID makes repeated activation calls idempotent. Draft creation uses a caller-supplied idempotency key with a unique database constraint. If an activation reply is lost, the caller reads the command status or retries activation. The API does not claim that no command was queued. + +### Applying and recovering + +Before each one-second fleet step, twin-core may take one prepared command across the whole fleet. It checks the command again against the current state, revision, model version, parameter fingerprint, expiry, and allowed control fields. It commits the new active control row, command result, and command event in one bounded transaction. Only after that commit does it replace the in-memory control set. A failed database write leaves the old controls in force. + +The apply transaction locks the activating user's row first. It requires an +active, undeleted account and the same role and session version that the API +verified at activation. Otherwise the result is rejected with +`authority_revoked`. The capability matrix remains in the API; twin-core checks +that the granted authority still exists. Logout or downgrade before application +therefore prevents the queued command from taking effect. Revocation after a +completed application does not undo the active controls. Every command write +uses the lock order user, command, then active control row. + +On startup, twin-core loads the active control row before its first model tick. That row always wins over controls in the last snapshot. A difference is recorded as a recovery event. A crash after the database commit but before the next model step therefore does not lose an active setpoint. Applied means the model's active control target changed; it does not promise a completed process step or any physical action. + +An expired, stale, invalid, or conflicted command becomes a terminal result with a reason. It is never retried by making a fresh command silently. A queued command may be cancelled while it remains queued. Row locking decides whether cancellation or application wins. A terminal command cannot move again. If the result of an apply commit is unknown, twin-core holds that well unavailable until it reads the active row again. It never assumes the old controls are still active. + +### Separate parameter changes + +An ADMIN uses the same authenticated identity, review, and transactional audit pattern for model parameter overrides. The parameter path has its own draft and publish states. Publication writes the existing append-only wellhead_model_parameter record and reports restart required. It does not use the live control queue or change model parameters during a tick. Twin-core remains the authority for parameter ranges and units. + +### Implementation gates + +Milestone 8B replaces the API's Compose database administrator credential with a scoped application role. The Drizzle mirror and both OpenAPI files cover the implemented tables and endpoints. It cannot claim a physical control safety rating. A real deployment would need a site risk assessment, a separate safety system, and device-specific command verification. + +## Consequences + +**Good.** + +- A user can distinguish draft, queued, and active controls. +- A crash or missed HTTP wake-up does not silently discard an accepted command. +- A stale or repeated request cannot override a newer active revision. +- The command record names the human actor and the model state it expected. + +**Costs.** + +- The API and twin-core share a small command lifecycle across HTTP and PostgreSQL. +- A control application adds one bounded database transaction near a model tick. +- Startup must reconcile the active control row with the last state snapshot. +- Existing installations need separate API database credentials before upgrading. + +**Accepted risks.** + +- The database can commit an active setpoint just before the process exits. The next process restores that setpoint; the intervening model tick is a visible gap. +- A database outage stops new control changes, while the model may continue with its last active controls. The control status must show the outage. +- A database transaction may commit just before its reply is lost. The command UUID and active revision make reconciliation possible, but the affected well stops ticking until that check completes. +- The 30-second expiry can reject a command during a restart. The operator must review and submit a new draft, so an old command never surprises them later. +- One operator can activate their own draft. This is suitable for a synthetic portfolio system, not a claim of independent approval. + +## Amendment, 2026-10-17: operating screens and outcome recovery + +The owner requested live control and parameter screens after the dashboard +usability work. The [operating workspace design](../design/operating-workspace.md) +keeps the existing draft, review, activation and publication boundaries. +OPERATIONS uses Controls. ADMIN uses Parameters and does not gain control authority. + +Bounded per-well history and exact own-key lookup let a user recover a missing +response without repeating a write. These reads recheck current account authority +and work without the model. Python supplies parameter definitions and running +values, so the frontend does not maintain another list of units or bounds. +The screens distinguish database acceptance, running adoption and restart-only +parameter changes. The single SQL schema needs no change for these additions. + +## Related + +- [Milestone 8B design](../design/twin-core-milestone-8b.md): implementation, operating steps, and verification results. + +- [ADR 0007](0007-control-and-what-if-boundaries.md): scenarios remain isolated from live controls. +- [ADR 0015](0015-control-and-safety.md): names the five editable control fields. +- [ADR 0011](0011-twin-core-communication.md): uses internal HTTP plus durable storage. +- [ADR 0021](0021-model-parameterization.md): parameter overrides require a distinct publish path. +- [ADR 0033](0033-role-model-and-access-control.md): OPERATIONS may issue control; ADMIN cannot. +- [ADR 0034](0034-audit-trail-scope.md): supplies the audit table, with stricter control-write behavior here. +- [NIST SP 800-82 Rev. 3](https://csrc.nist.gov/pubs/sp/800/82/r3/final): OT security guidance on access control and availability. +- [ISA/IEC 62443 series](https://www.isa.org/standards-and-publications/isa-standards/isa-iec-62443-series-of-standards): industrial security lifecycle and risk framing. diff --git a/docs/adr/0039-alarm-operator-actions.md b/docs/adr/0039-alarm-operator-actions.md new file mode 100644 index 0000000..63ba7af --- /dev/null +++ b/docs/adr/0039-alarm-operator-actions.md @@ -0,0 +1,81 @@ +# ADR 0039: Record acknowledgement and timed shelving separately from alarm conditions + +- Status: Accepted +- Date: 2026-10-17 +- Implementation: Implemented and verified locally +- Decision: Preserve the existing alarm evaluator. Store acknowledgement for one + exact event, and timed shelving for one well/rule pair, with atomic action + receipts and audit events. Only OPERATIONS may perform these actions. + +## Context + +ADR 0032 delivered active alarm inspection and required an explicit lifecycle +design before adding operator actions. The owner has now authorized that work. +An acknowledgement means someone has recognized an event. It does not establish +that the condition has cleared. A temporary shelf must survive repeated activations +of the same condition and expire without relying on a running cleanup worker. + +The evaluator already debounces opening and clearing and prevents duplicate open +events for a well/rule pair. Its event identity contains a PostgreSQL bigint and +a timestamp with microseconds. JavaScript numbers and Date objects cannot preserve +both parts exactly. The API's current role grants only reads on those events. + +## Options considered + +1. Add flags to the alarm event and change the evaluator. This couples operator + actions to process truth, complicates retention and needs existing-table changes. +2. Store actions only in the general audit JSON. This leaves revision checks and + current shelving state without a clear authoritative record. +3. Add ordinary acknowledgement, shelving and action-receipt tables. This keeps + event truth intact and lets PostgreSQL enforce unique acknowledgements and + serialize shelf revisions. Choose this option. + +## Decision + +Acknowledgement belongs to the exact `(event_id, triggered_at)` pair. Shelving +belongs to `(wellhead_id, alarm_rule_id)` and lasts from 5 to 480 whole minutes. +Expiry uses the database clock on reads. An operator can end a shelf early or +explicitly replace its deadline after reviewing the current revision. + +OPERATIONS uses the existing `alarm:acknowledge` capability for all three actions. +ADMIN keeps administrative authority and does not inherit operating authority. +Every write requires a reason, explicit confirmation and an idempotency key. +The transaction rechecks current account authority, changes lifecycle state, +records an immutable receipt and writes the audit event together. Failure of +any part rolls back all of them. Reusing a key with different inputs fails. + +Active totals continue to count every open event, including acknowledged or +shelved events. Needs acknowledgement includes retained, unacknowledged events, +including events that have returned to normal, except while their rule is shelved. +A separate shelf list exposes every current shelf even when no event is active. +History preserves process state and operator actions as separate information. + +The single canonical SQL file gains only new ordinary tables and indexes. The +verified extension procedure preserves existing objects and rows. Lifecycle +records do not reference the retained event hypertable, so they do not hold its +chunks in storage. Action receipts and acknowledgement records remain durable +until a separately designed archival policy replaces that decision. + +## Consequences + +Operators can distinguish recognition, process recovery and temporary shelving. +A lost HTTP reply has an exact receipt lookup. Concurrent shelf edits fail on a +revision conflict. A shelf expires even if the API stops and later restarts. +The lifecycle tables add durable operational metadata and another bounded API +transaction; they do not actuate the model or change alarm rules. + +The design follows the acknowledgement and timed-shelving concepts in the public +OPC Foundation reference. It does not implement an OPC UA server or claim complete +ISA-18.2/IEC 62682 conformance. A complete site alarm program also needs an alarm +philosophy, rationalization, operator procedures and ongoing performance review. +Revisit this decision for physical plant use, independently approved shelving, +site-specific maximum durations or a durable alarm archive beyond current retention. + +## References + +- [Detailed design and verification](../design/alarm-lifecycle.md) +- [ADR 0032](0032-alarm-center-scope.md) +- [OPC Foundation definitions](https://reference.opcfoundation.org/specs/OPC-10000-9/3.1) +- [Acknowledgement identity and comment](https://reference.opcfoundation.org/specs/OPC-10000-9/5.7.3) +- [Timed shelving](https://reference.opcfoundation.org/specs/OPC-10000-9/5.8.17.4) +- [IEC 62682 scope](https://webstore.iec.ch/en/publication/65543) diff --git a/docs/adr/README.md b/docs/adr/README.md index 3f590cd..d9aa4c9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,55 +14,62 @@ Each ADR should answer: - `Proposed` - `Accepted` +- `Accepted, amended`: a later section changes or extends the decision +- `Accepted, corrected`: a later section corrects the original decision - `Superseded` - `Deprecated` ## Index Status is whether the decision was accepted. Implementation is whether the -code does it yet. They are different questions, and only one of them was -previously written down. +code does it yet. Read the latest amendment for the current decision; older +context and dated implementation notes remain historical evidence. -| ADR | Decision | Implementation | -| --- | --- | --- | -| [0001](0001-project-positioning.md) | Position The Project As A Wellhead Monitoring Simulation Before Digital Twin | Implemented | -| [0002](0002-lightweight-digital-twin-architecture.md) | Use A Layered Lightweight Digital Twin Architecture | Partial | -| [0003](0003-stateful-process-model.md) | Make A Stateful Process Model The Twin Core | Partial | -| [0004](0004-industrial-interface.md) | Keep Modbus TCP As The Industrial Edge Interface | Implemented | -| [0005](0005-data-historian.md) | Use PostgreSQL And TimescaleDB As The Historian | Implemented | -| [0006](0006-schema-management.md) | Use SQL Migrations As The Canonical Schema Source | Implemented | -| [0007](0007-control-and-what-if-boundaries.md) | Separate Live Control From What-If Simulation | Not started | -| [0008](0008-production-deployment-baseline.md) | Use A Containerized Service Baseline | Implemented | -| [0009](0009-model-fidelity.md) | Start With A Lightweight Deterministic Model | Partial | -| [0010](0010-model-runtime.md) | Run The Twin Core As A Separate Python Service | Implemented | -| [0011](0011-twin-core-communication.md) | Use Internal HTTP Plus Durable Database Persistence | Partial | -| [0012](0012-state-storage.md) | Keep Active Twin State In Memory With Durable Snapshots | Partial | -| [0013](0013-prediction-strategy.md) | Start With On-Demand Forecasts And Evolve To Hybrid Forecasting | Not started | -| [0014](0014-scenario-execution.md) | Run What-If Scenarios Against Isolated Cloned State | Not started | -| [0015](0015-control-and-safety.md) | Restrict Live Control To Actuator-Like Variables | Not started | -| [0016](0016-api-contract.md) | Use REST APIs Documented With OpenAPI | Implemented | -| [0017](0017-observability-baseline.md) | Use Structured Logs, Health Checks, And Metrics As The Observability Baseline | Implemented | -| [0018](0018-deployment-target.md) | Use Docker Compose As The First Production-Grade Deployment Target | Implemented | -| [0019](0019-model-equations.md) | Use A Reduced-Order Mechanistic Wellhead Model | Implemented | -| [0020](0020-model-tick-semantics.md) | Use Fixed Timestep Model Updates | Partial | -| [0021](0021-model-parameterization.md) | Use Version-Controlled Defaults With Database Overrides | Partial | -| [0022](0022-recovery-semantics.md) | Restore From Snapshots And Mark Recovery Gaps | Partial | -| [0023](0023-default-parameter-set.md) | Use Documented Synthetic Engineering Defaults | Implemented | -| [0024](0024-twin-persistence-schema.md) | Use Relational Twin Metadata With Time-Series Run Outputs | Not started | -| [0025](0025-forecast-horizon-confidence.md) | Use Short Operational Forecasts With Heuristic Confidence | Not started | -| [0026](0026-retention-and-compression.md) | Use Tiered Retention And TimescaleDB Compression | Implemented | -| [0027](0027-historical-branching.md) | Stage Historical Scenario Branching From Model Snapshots | Not started | -| [0028](0028-api-specification-workflow.md) | Use Per-Service OpenAPI Specs Committed To Documentation | Implemented | -| [0029](0029-metrics-stack.md) | Expose Prometheus-Compatible Metrics With Optional Observability Stack | Partial | -| [0030](0030-portfolio-deployment-scope.md) | Target A Self-Contained Portfolio Deployment | Implemented | -| [0031](0031-portfolio-demo-narrative.md) | Optimize The Demo For Fast Reviewer Understanding | Partial | -| [0032](0032-alarm-center-scope.md) | Implement Active Alarm Inspection Before Alarm Lifecycle Workflows | Implemented | -| [0033](0033-role-model-and-access-control.md) | Separate Operating Authority From Administrative Authority | Implemented | -| [0034](0034-audit-trail-scope.md) | Record An Audit Trail For Actors, Not For Auditors | Implemented | -| [0035](0035-metadata-reload.md) | How Metadata Changes Reach A Running Service | Implemented | -| [0036](0036-model-telemetry-through-modbus.md) | Send Model Telemetry Through The Existing Modbus Path | Implemented | +| ADR | Decision | Status | Implementation | +| --- | --- | --- | --- | +| [0001](0001-project-positioning.md) | Position The Project As A Wellhead Monitoring Simulation Before Digital Twin | Accepted, amended | Implemented | +| [0002](0002-lightweight-digital-twin-architecture.md) | Use A Layered Lightweight Digital Twin Architecture | Accepted | Implemented for the synthetic reference well | +| [0003](0003-stateful-process-model.md) | Make A Stateful Process Model The Twin Core | Accepted | Implemented | +| [0004](0004-industrial-interface.md) | Keep Modbus TCP As The Industrial Edge Interface | Accepted | Implemented | +| [0005](0005-data-historian.md) | Use PostgreSQL And TimescaleDB As The Historian | Accepted, amended | Implemented; engine recovery verified separately | +| [0006](0006-schema-management.md) | Keep SQL As The Canonical Schema Source | Accepted, amended | Implemented; single-file amendment adopted | +| [0007](0007-control-and-what-if-boundaries.md) | Separate Live Control From What-If Simulation | Accepted, amended | Separate scenario, control and parameter screens implemented | +| [0008](0008-production-deployment-baseline.md) | Use A Containerized Service Baseline | Accepted | Implemented | +| [0009](0009-model-fidelity.md) | Start With A Lightweight Deterministic Model | Accepted | Implemented | +| [0010](0010-model-runtime.md) | Run The Twin Core As A Separate Python Service | Accepted | Implemented | +| [0011](0011-twin-core-communication.md) | Use Internal HTTP Plus Durable Database Persistence | Accepted | Implemented | +| [0012](0012-state-storage.md) | Keep Active Twin State In Memory With Durable Snapshots | Accepted | Implemented | +| [0013](0013-prediction-strategy.md) | Start With On-Demand Forecasts And Evolve To Hybrid Forecasting | Accepted, amended | On-demand and scheduled forecasts implemented | +| [0014](0014-scenario-execution.md) | Run What-If Scenarios Against Isolated Cloned State | Accepted, amended | Implemented | +| [0015](0015-control-and-safety.md) | Restrict Live Control To Actuator-Like Variables | Accepted | Implemented | +| [0016](0016-api-contract.md) | Use REST APIs Documented With OpenAPI | Accepted | Implemented | +| [0017](0017-observability-baseline.md) | Use Structured Logs, Health Checks, And Metrics As The Observability Baseline | Accepted, amended | Implemented; dashboard readiness includes its API path | +| [0018](0018-deployment-target.md) | Use Docker Compose As The First Production-Grade Deployment Target | Accepted | Implemented | +| [0019](0019-model-equations.md) | Use A Reduced-Order Mechanistic Wellhead Model | Accepted, amended | Implemented | +| [0020](0020-model-tick-semantics.md) | Use Fixed Timestep Model Updates | Accepted, amended | Implemented | +| [0021](0021-model-parameterization.md) | Use Version-Controlled Defaults With Database Overrides | Accepted, amended | Implemented | +| [0022](0022-recovery-semantics.md) | Restore From Snapshots And Mark Recovery Gaps | Accepted, amended | Implemented | +| [0023](0023-default-parameter-set.md) | Use Documented Synthetic Engineering Defaults | Accepted, amended | Implemented | +| [0024](0024-twin-persistence-schema.md) | Use Relational Twin Metadata With Time-Series Run Outputs | Accepted, amended | Implemented | +| [0025](0025-forecast-horizon-confidence.md) | Use Short Operational Forecasts With Heuristic Confidence | Accepted | Implemented | +| [0026](0026-retention-and-compression.md) | Use Tiered Retention And TimescaleDB Compression | Accepted, amended | Implemented | +| [0027](0027-historical-branching.md) | Stage Historical Scenario Branching From Model Snapshots | Accepted, amended | Implemented | +| [0028](0028-api-specification-workflow.md) | Use Per-Service OpenAPI Specs Committed To Documentation | Accepted | Implemented | +| [0029](0029-metrics-stack.md) | Expose Prometheus-Compatible Metrics With Optional Observability Stack | Accepted, amended | Implemented | +| [0030](0030-portfolio-deployment-scope.md) | Target A Self-Contained Portfolio Deployment | Accepted | Implemented | +| [0031](0031-portfolio-demo-narrative.md) | Optimize The Demo For Fast Reviewer Understanding | Accepted, amended | Partial; workspace implemented, final portfolio delivery pending | +| [0032](0032-alarm-center-scope.md) | Implement Active Alarm Inspection Before Alarm Lifecycle Workflows | Accepted, amended | Implemented | +| [0033](0033-role-model-and-access-control.md) | Separate Operating Authority From Administrative Authority | Accepted | Implemented | +| [0034](0034-audit-trail-scope.md) | Record An Audit Trail For Actors, Not For Auditors | Accepted, amended | Implemented | +| [0035](0035-metadata-reload.md) | How Metadata Changes Reach A Running Service | Accepted, amended | Implemented | +| [0036](0036-model-telemetry-through-modbus.md) | Send Model Telemetry Through The Existing Modbus Path | Accepted, amended | Implemented | +| [0037](0037-session-revocation.md) | Logout Ends Every Session Of The User, On The Server | Accepted | Implemented | +| [0038](0038-live-control-command-lifecycle.md) | Apply Live Model Controls Through Durable, Reviewed Commands | Accepted, amended | Implemented | +| [0039](0039-alarm-operator-actions.md) | Record Acknowledgement and Timed Shelving Separately From Alarm Conditions | Accepted | Implemented | -Of 36 decisions: 17 implemented, 10 not started, 9 partial. +Of 39 decisions, 38 have their agreed local implementation. ADR 0031 retains +external portfolio publication and contributor-consent gates. The twin-core process model now feeds the Modbus gateway and historian. -Forecasts, scenarios, and durable model state snapshots remain planned. +Durable model state, forecasts, isolated scenarios, live synthetic controls, +and authenticated parameter publication are implemented. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 2d83917..587694b 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -2,6 +2,11 @@ ## Current System +The [operating workspace](../design/operating-workspace.md) adds separate control +and parameter screens to the well view. The API keeps current-user authority, +revision checks and atomic audit records. Python supplies parameter metadata. +Proposal history supports recovery after a missing write response. + The current system is a simulated industrial monitoring pipeline: ```text @@ -19,6 +24,8 @@ each well on a fixed one-second step. The gateway reads one complete fleet snapshot and serves all 18 seeded model signals as Modbus registers. Ingestion polls the gateway and stores source-labelled readings. The seven diagnostic signals added in model version 0.3.0 remain synthetic estimates. +Twin-core also saves model state in TimescaleDB and restores compatible state +after a restart. Recovery decisions and wall-clock jumps are recorded there. The [Milestone 4 design](../design/twin-core-milestone-4.md) explains the service boundary and data quality rules. The [Milestone 5 design](../design/twin-core-milestone-5.md) @@ -26,26 +33,61 @@ explains the original process equations, units, and limits. The [reference-well design](../design/reference-well-physics.md) explains the additional phase and diagnostic equations. The [Milestone 6 design](../design/twin-core-milestone-6.md) describes the Modbus -mapping and freshness rules. +mapping and freshness rules. The [Milestone 7 design](../design/twin-core-milestone-7.md) +describes snapshots, parameter overrides, and restart recovery. The +[Milestone 8 design](../design/twin-core-milestone-8.md) describes saved forecasts +and isolated scenarios. The [8B design](../design/twin-core-milestone-8b.md) +describes live synthetic controls and parameter publication. +The [Milestone 9 design](../design/twin-core-milestone-9.md) connects those services +to the browser workspace. +The [usability design](../design/dashboard-usability.md) records the Guide, +contextual explanations and consistent page states. Saved results retain +their original inputs independently of the current draft. ## Current Dashboard Layer -The dashboard currently exposes the monitoring foundation: +The browser reaches the API through the dashboard's nginx proxy. It refreshes +the API address using Docker DNS, so container replacement needs no proxy reload. +Dashboard readiness checks both the page and /api/ready, including the API's +database dependency. Telemetry freshness remains a separate data-level check. + +The dashboard exposes monitoring and model analysis: - fleet-level overview for all simulated wellheads - latest recorded telemetry snapshots, which may be partial after a read failure - active alarms from database alarm evaluation - analytics charts for pressure, temperature, flow, water cut, and GOR - per-wellhead detail pages with current readings, alarms, and trends -- an Alarm Center with severity, threshold, age, and links to wellheads +- an Alarm Center with separate condition, acknowledgement and timed-shelf state +- per-well model status, successful process time, recovery details, and installed controls +- explicit forecast and scenario submissions with saved baseline comparisons +- canonical signal units, heuristic confidence reasons, and sampled threshold results +- bounded saved-run history and direct result URLs + +React Router loaders read data and form actions create projections. Status polls +preserve draft inputs and do not repeat model submissions or reload saved points. +The internal model-status read is protected by the service token. The public API +returns only the status fields the dashboard needs, without model state or secrets. +If this optional read fails, the historian view remains available. + +Monitoring shows installed controls as read-only values. OPERATIONS users open +the separate Controls screen to draft, review and activate a command. ADMIN +users open Parameters to publish an override that requires restart. Scenario +forms use private model copies. Saved results retain their original inputs, +so editing a new draft cannot change an older chart. + +The operating and historical-scenario screens reuse existing tables. Scheduled +forecasts and alarm lifecycle actions add ordinary tables through the verified +schema extension. Every definition remains in `data/sql/schema.sql`. Ingestion rejects a failed or mixed-time Modbus poll before inserting a batch. The API can still show partial or stale latest views during source failure, metadata changes, or while older readings expire. -## Target Lightweight Digital Twin +## Lightweight Digital Twin -The target architecture adds a twin core between asset metadata and telemetry output: +The running architecture puts twin-core between asset metadata and telemetry +output. It also serves bounded forecasts and isolated scenarios on request: ```text Asset metadata @@ -53,31 +95,41 @@ Asset metadata -> Stateful wellhead process model -> Simulated telemetry -> Historian - -> Prediction and what-if services - -> Operational dashboard and API + -> Twin-core forecast and scenario runner + -> Authenticated API + -> Operational dashboard ``` -## Target Runtime Components +## Runtime Components - **Twin core**: maintains current process state for each wellhead. - **Process model**: updates pressure, flow, temperature, valve state, pump state, and degradation over time. - **Telemetry adapter**: converts supported model fields into Modbus-readable register values. -- **Historian**: stores source-labelled readings and alarm events. Model state - snapshots and predictions remain planned. -- **Control API**: accepts bounded operational inputs. -- **Scenario API**: runs what-if simulations without mutating live state. -- **Dashboard**: shows live state, historical trends, alarms, predictions, and scenario outputs. +- **Historian**: stores source-labelled readings, alarm events, model state + snapshots, recovery events, and saved forecast and scenario runs. +- **Forecast and scenario API**: copies current model state, advances the same + one-second model privately, and saves complete runs. It never applies a live + control command. See [Milestone 8](../design/twin-core-milestone-8.md). +- **Dashboard**: shows current state, historical trends, alarms, model status, + saved forecasts, and scenario comparisons. It keeps installed controls and + scenario inputs in separate views. +- **Live control API**: saves an operator draft and explicit activation. Twin-core + checks current authority and applies the durable targets before a model tick. + Active control rows take precedence over older snapshots during recovery. +- **Parameter publication API**: validates administrator proposals through the + Python resolver and writes an attributed override. A restart loads it into + the running model. -## Digital Twin Evolution Path +## Implemented model and future measured-data work -The project becomes a credible lightweight digital twin by adding capabilities in this order: +Monitoring, the stateful process model, forecasts and private scenarios are +implemented. They all use the synthetic reference well. Forecast confidence is +a heuristic assessment of model limits, not a measured accuracy probability. -1. **Explainable monitoring**: overview, detail pages, alarm center, trend explorer, and data freshness. -2. **Stateful process model**: deterministic wellhead state that evolves over fixed timesteps. -3. **Observed vs simulated comparison**: store both measured telemetry and model-estimated state. -4. **Divergence detection**: flag when observed readings move away from expected model behavior. -5. **Forecasting**: short-horizon predictions with confidence and clear assumptions. -6. **What-if scenarios**: cloned-state simulations that do not mutate live state. +Measured telemetry comparison and divergence detection remain future work. +They need field measurements, calibration and a validation plan before the +system can compare a physical well with its model. Their absence does not block +the current synthetic workflows. ## Key Boundary @@ -85,10 +137,138 @@ The project should not claim to be a high-fidelity reservoir simulator. The targ ## Diagrams -- [Data model](../relevant_visuals/data-model.pdf): the - relational schema, showing the asset hierarchy, parameter metadata, and the - time-series tables. -- [Flow diagram](../relevant_visuals/flow-diagram.pdf): the older random simulator path to the dashboard. +### Running services and write paths + +```mermaid +flowchart TB + Browser[Dashboard in browser] --> Proxy[nginx: pages and API proxy] + Proxy --> API[Hono API: sessions and authority] + API -->|reads and audited writes| DB[(PostgreSQL and TimescaleDB)] + API -->|authenticated analysis requests| Twin[Twin-core] + API -->|committed command ID hint| Twin + DB -->|metadata, snapshots and command queue| Twin + Twin -->|snapshots, controls and saved results| DB + Twin -->|complete fleet snapshot| Gateway[Modbus gateway] + Gateway -->|holding registers| Ingestion[Ingestion] + Ingestion -->|complete reading batch| DB + Worker[Forecast worker inside twin-core] -->|claim job and save result| DB + Worker -->|copy running state and share calculation capacity| Twin + Prometheus[Optional Prometheus] -->|scrape metrics| API + Prometheus -->|scrape metrics| Twin + Prometheus -->|scrape metrics| Gateway + Prometheus -->|scrape metrics| Ingestion + Grafana[Optional Grafana] -->|query metrics| Prometheus +``` + +The database also evaluates alarm conditions when ingestion inserts readings. +Alarm operator actions use the API transaction path. They do not change the +condition evaluator or send a control command. The forecast worker is a thread +inside twin-core, not a separate Compose service. + +### Selected database relationships + +These diagrams show the main stored relationships. The canonical SQL defines +all columns, constraints and deletion rules. Unquoted SQL names appear below in +their lowercase PostgreSQL form. + +```mermaid +erDiagram + field ||--o{ location : contains + location ||--o{ wellhead : contains + device ||--o| wellhead : serves + device ||--o{ deviceparametermapping : maps + parametertype ||--o{ deviceparametermapping : describes + wellhead ||--o{ parameterreading : records + deviceparametermapping ||--o{ parameterreading : decodes + parametertype ||--o{ alarmrule : checks + alarmrule ||--o{ alarmevent : opens + wellhead ||--o{ alarmevent : experiences + wellhead ||--o{ twin_state_snapshot : saves + wellhead |o--o{ twin_recovery_event : records + wellhead ||--o{ wellhead_model_parameter : overrides +``` + +```mermaid +erDiagram + users ||--o{ control_command : proposes + wellhead ||--o{ control_command : targets + control_command ||--o{ control_command_event : records + wellhead ||--o| wellhead_control_state : installs + wellhead ||--o{ model_parameter_change : receives + users ||--o{ forecast_schedule : owns + wellhead ||--o| forecast_schedule : schedules + forecast_schedule ||--o{ forecast_job : claims + prediction_run |o--o{ forecast_job : completes + prediction_run ||--o{ prediction_point : contains + wellhead ||--o{ prediction_run : projects + wellhead ||--o{ scenario_run : compares + scenario_run ||--o{ scenario_point : contains + alarm_action ||--o| alarm_acknowledgement : records + alarm_action ||--o{ alarm_shelf : supports + wellhead ||--o{ alarm_shelf : has + alarmrule ||--o{ alarm_shelf : governs +``` + +An acknowledgement uses the event ID and exact trigger time, but has no foreign +key to `alarmevent`. That lets event retention remove old chunks while the action +receipt remains. A shelf belongs to a well/rule pair and can outlive one active +event. A completed forecast job references its saved prediction run; other job +outcomes have no result. Scenario starting-state identity lives in the saved +JSON rather than a foreign key that would block snapshot retention. + +### Historical diagrams + +- [Original data model](../relevant_visuals/data-model.pdf): early metadata and historian sketch. +- [Original flow diagram](../relevant_visuals/flow-diagram.pdf): the older random simulator path. + +These PDFs predate the current schema. The complete definition in `data/sql/schema.sql` is +canonical where the two disagree. The [schema design](../design/consolidated-schema.md) +explains installation, structural checks, and adoption of existing data. +Compose and CI pin PostgreSQL 16.15 with TimescaleDB 2.30.2. The +[engine upgrade design](../design/database-engine-upgrade.md) covers converting +an existing volume, verifying recovery, and accepting the new generated reference. + +## Historical scenario source + +A scenario can copy an exact retained model snapshot. The model service validates +the saved timestamp, physical state, version and parameter fingerprint before +running private baseline and scenario copies. The saved run carries its source +identity and complete inputs. Historical scenarios use alarm rules active at +execution; chart retention follows simulated dates. Forecasts continue from +current state. See [historical scenarios](../design/historical-scenarios.md). + +## Scheduled forecasts + +The Forecast page also shows an optional administrator-owned schedule. A separate +model-service worker claims bounded jobs from PostgreSQL, copies current running +state and calculates with the same model as manual forecasts. It checks owner +authority and claim expiry before atomically saving the result and completion. +The page distinguishes the latest attempt from the latest successful result and +shows each timestamp. No job writes live controls. See [scheduled forecasts](../design/scheduled-forecasts.md). + +## Optional service metrics + +The `observability` Compose profile collects the API, model, gateway and ingestion +metrics every 15 seconds. Prometheus stores up to seven days of metrics, subject +to a 512MB block retention cap. Grafana provisions the Service health dashboard +from the repository. Separate rows show endpoint reachability and service +readiness; absent or stale samples remain unknown. Both host ports bind to +loopback, and Grafana uses a separate local account. The dashboard proxy also +exposes the API metrics at `/api/metrics` without login. These tools cannot write +to the historian, model controls or alarm lifecycle. See the +[design](../design/observability.md) and [runbook](../../observability/README.md). + +## Alarm operator actions + +The SQL evaluator owns condition truth in `alarmevent`. The API stores an exact +event acknowledgement or a well/rule shelf in separate ordinary tables. One +transaction rechecks OPERATIONS authority, records the action receipt, changes +lifecycle state and writes the audit event. A unique actor/request key makes +explicit retries return the same result. Shelf revisions reject competing edits. -These predate the current schema. The migrations in `data/sql/migrations` are -canonical where the two disagree. +The Alarm Center separates Needs acknowledgement, All active, Shelved rules and +retained History. Active totals include acknowledged and shelved conditions. A +shelf expires by database time and still applies if the condition clears and +retriggers before its deadline. Event IDs stay decimal strings; exact UTC +timestamps retain their microseconds. The application role cannot alter alarm +events or existing receipts. See the [alarm design](../design/alarm-lifecycle.md). diff --git a/docs/architecture/production-readiness.md b/docs/architecture/production-readiness.md index ce63062..9b0338f 100644 --- a/docs/architecture/production-readiness.md +++ b/docs/architecture/production-readiness.md @@ -1,85 +1,107 @@ -# Production Readiness - -This checklist defines what "production-grade" should mean for this repository. - -## Documentation - -- Root README clearly states what the system is and is not. -- ADRs exist for all major architectural decisions. -- Local setup, deployment, and troubleshooting docs exist. -- API contracts are documented. -- Data model and migration strategy are documented. - -## Reliability - -- Services use health checks instead of fixed startup sleeps. -- Services retry transient database and Modbus failures with backoff. -- Docker Compose uses correct internal ports and paths. -- The API, dashboard, database, simulator, gateway, and ingestion services run together. -- Ingestion handles malformed Modbus reads without stopping the pipeline. - -## Security - -- Secrets are loaded from environment variables or secret stores. -- `.env.example` exists; real `.env` files are ignored. -- Auth never returns password hashes. -- Login issues secure HTTP-only cookies or bearer tokens. -- Protected routes validate sessions consistently. -- Public ports are intentional and documented. - -## Secret Management - -Secrets are read from environment variables. Nothing reads a credential from a -file, a config table, or a hardcoded default, so moving to a secret store means -changing how the environment is populated and nothing else. - -What holds today: - -- `.env` is git-ignored. `.env.example` carries placeholders only. -- Every service reads its secrets through one function: `required_env` in - `data/src/telemetry_common.py`, and `apps/api/src/config.ts` for the API. -- `JWT_SECRET` is validated at boot and the service refuses to start without a - usable one. A misconfigured deployment fails immediately rather than serving - 500s. -- No secret is written to a log. The API redacts bound query parameters before - logging a database error, because Drizzle includes them in the message. - -What a real deployment changes, and only this: - -- Compose reads `.env`. A different orchestrator injects the same variable - names from its own secret store: Kubernetes Secrets, AWS Secrets Manager, or - Vault with an agent that populates the environment. -- The names are already the interface. `POSTGRES_PASSWORD` and `JWT_SECRET` do - not care where their value came from. - -What is still missing: - -- No rotation. Changing `JWT_SECRET` invalidates every session at once, because - tokens carry no key id. Supporting rotation means signing with a key id and - accepting the previous key during a window. -- The demo account password is in the repository on purpose, and the bootstrap - administrator password is generated and logged once. Both are local-stack - conveniences and neither belongs in a real deployment. - -## Data - -- There is one canonical schema migration strategy. -- Time-series tables, views, and continuous aggregates are reproducible. -- The system distinguishes raw readings, model state, predictions, and scenario results. -- Database constraints protect asset and parameter integrity. - -## Observability - -- Services emit structured logs. -- Health endpoints exist for API and model services. -- Metrics are available for ingestion lag, Modbus errors, database writes, alarm count, and model update duration. -- Errors include enough context for debugging without leaking secrets. - -## Testing - -- Unit tests cover model behavior and alarm rules. -- Integration tests cover ingestion and API/database access. -- Contract tests cover dashboard/API responses. -- Scenario tests verify what-if simulation behavior. -- CI runs formatting, linting, type checks, and tests. - +# Production readiness + +Status reviewed against application commit `82c9baf`, 2026-10-17. + +The local synthetic application has verification evidence for its main workflows. +It is not a field-calibrated system or a deployed production service. This record +separates implemented behavior from open work and the limits of the checks run. + +## How to read the status + +- **Verified locally:** the linked record describes a completed check against + the implementation at that time. It does not imply remote CI passed. +- **Implemented, limited:** code exists, with the limitation stated in the row. +- **Open:** the delivery action or capability remains unfinished. +- **Deferred:** the owner explicitly chose to postpone the check. +- **Outside current scope:** it needs a separate design and authorization. + +## Local implementation and evidence + +| Area | Status | Evidence and limits | +| --- | --- | --- | +| Fresh installation | Verified locally | The [usability delivery](../design/dashboard-usability.md#delivery-evidence-2026-10-17) built and started a clean candidate with fresh storage | +| Current deployment | Verified locally | The [alarm delivery](../design/alarm-lifecycle.md#verification-on-2026-10-17) checked eight healthy services, four alarm views and unchanged controls for 12 wells | +| Browser-to-API recovery | Verified locally | [ADR 0017](../adr/0017-observability-baseline.md) records dependency failure and API address-change recovery | +| Telemetry failure and freshness | Verified locally | [Engine follow-up](../design/database-engine-upgrade.md#delivery-review-and-operational-checks-2026-10-16) records gateway recovery and poll-collision checks; stale data remains visible | +| Model state and restart | Verified locally | [Milestone 7](../design/twin-core-milestone-7.md) covers snapshots, restored state and recorded gaps; physical sleep/wake is separate | +| Controls and parameters | Verified locally | [Operating screens](../design/operating-workspace.md#verification-on-2026-10-17) exercised real command application, restoration and parameter adoption after restart | +| Current and historical analyses | Verified locally | [Milestone 8](../design/twin-core-milestone-8.md) and [historical scenarios](../design/historical-scenarios.md) cover private model copies and saved results | +| Scheduled forecasts | Verified locally | [Schedule verification](../design/scheduled-forecasts.md#implementation-verification-on-2026-10-17) covers authority, competing claims, expiry and atomic result saving | +| Alarm lifecycle | Verified locally | [Alarm verification](../design/alarm-lifecycle.md#verification-on-2026-10-17) covers exact identities, permissions, competing writes, expiry and lost replies | +| Schema and existing data | Verified locally | [Consolidation](../design/consolidated-schema.md), [engine conversion](../design/database-engine-upgrade.md) and [extensions](../design/schema-extension.md) record separate preservation checks | +| Backup restoration | Verified locally | The final alarm rehearsal preserved 3,657,848 records across 26 existing tables; this is a rehearsal, not an automated backup service | +| Retention and compression | Implemented, limited | [ADR 0026](../adr/0026-retention-and-compression.md#amendment-2026-10-17-record-the-installed-retention-policy) lists actual policies and ordinary tables without automatic expiry | +| Health, readiness and metrics | Verified locally | [Observability verification](../design/observability.md#verification-on-2026-10-17) covers four scrape targets, separate readiness and reachability, and failed-source behavior | +| Shared contracts and API behavior | Verified locally | The final alarm record reports 127 API tests and 40 shared-contract/frontend tests; OpenAPI validation checks structure, not every runtime response | +| Accessibility | Implemented, limited | [Browser and Orca evidence](../design/milestone-9/accessibility-verification.md) states the tested combinations; later designs record their own checks, without claiming universal coverage | +| CI configuration | Implemented, limited | [CI](../../.github/workflows/ci.yml) defines types, lint, formatting, Python, schema/API, image, proxy, browser and metrics checks; remote execution for this delivery remains open | + +## Security and accounts + +The API issues HTTP-only session cookies. The local HTTP setup uses +`COOKIE_SECURE=false`; a deployment that terminates TLS must configure the secure +flag and allowed origins explicitly. Logout increments the account's session +version and invalidates its existing tokens. Operating writes recheck account +authority and record an attributed result. ADMIN and OPERATIONS remain separate. + +The API uses a restricted database login. Twin-core has separate grants for +metadata, snapshots, projections and command application. The gateway, ingestion +and schema setup still use the configured database administrator. This is a local deployment +boundary, not a claim that every service has a separate least-privilege account. + +Ordinary role guards use the role in the session token. Some reads can therefore +retain an old role until session revocation or expiry. Sensitive operating paths +recheck the database account, and scheduled forecasts check their owner's current +authority. Public account disabling and role-editing routes are not implemented. + +Routine logs exclude credentials. The bootstrap administrator password is +deliberately logged once by the installer. The repository also contains a known +demo USER password. Both are documented local conveniences. Private security +reports follow the [security policy](../../.github/SECURITY.md). + +## Secrets and network exposure + +Runtime configuration reads secrets from environment variables. Compose loads +the ignored `.env`; `.env.example` contains placeholders. The application validates +required database settings, the JWT signing secret and the internal service token. +Grafana separately validates its optional administrator password. + +A secret store could populate the same environment names, but its access control, +rotation, deployment and recovery still need implementation and verification. +Changing `JWT_SECRET` currently invalidates all sessions. There is no overlapping +signing-key rotation window. + +| Default host port | Service | Boundary | +| --- | --- | --- | +| 8090 | Dashboard and `/api` proxy | Published on host interfaces; plain HTTP in the local setup | +| 5434 | Database | Published on host interfaces; database authentication required | +| 5020 | Modbus gateway | Published on host interfaces; no application login | +| 9090, optional | Prometheus | Host loopback only; no login | +| 3010, optional | Grafana | Host loopback only; separate administrator account | + +The API, twin-core and telemetry health ports otherwise stay inside the Compose +network. The host-development recipe explicitly adds a loopback twin-core port. +The dashboard proxy exposes `/api/metrics` without login, alongside the API's +health probes. It reports service activity rather than well readings. +Remote exposure needs its own network, authentication and TLS design. + +## Open work and deployment limits + +| Item | Status | What closes it | +| --- | --- | --- | +| Remote CI and publication | Open | Publish the approved branch and inspect the actual remote results | +| Contributor licence consent | Open | Record the required contributor consent and complete the licence decision | +| Physical host sleep/wake | Deferred by owner | A coordinated physical sleep/wake check with recorded recovery observations | +| Full assistive-technology coverage | Open | Additional human testing across the intended browser and assistive-technology combinations | +| Routine backup operation | Outside current scope | A scheduled backup, retention, monitoring and restore-drill design | +| Permanent command, job and alarm-action archives | Outside current scope | An archive policy that preserves receipt references and audit meaning | +| Field accuracy | Outside current scope | Measured well data, calibration and independent validation for a defined use | +| Physical equipment control | Outside current scope | A separate device, authority and safety design | +| General user/role, alarm-rule and asset editing | Outside current scope | Separate reviewed administration workflows | +| Remote production deployment | Outside current scope | TLS, network boundaries, credential rotation, service grants and operational ownership | +| Multiple model instances and failover | Outside current scope | Explicit ownership and recovery design; current model operation assumes one instance | + +These entries do not reopen completed remediation phases. They state the limits +of the local deployment and the remaining delivery actions. The documentation +update reviewed existing evidence; it did not rerun the application test suites, +perform another database conversion or check remote publication state. diff --git a/docs/demo/demo-script.md b/docs/demo/demo-script.md index 06fccff..e55c7ee 100644 --- a/docs/demo/demo-script.md +++ b/docs/demo/demo-script.md @@ -1,88 +1,152 @@ # Demo Script -This script describes the intended portfolio-review flow. - ## Goal -Show that the project is an industrial wellhead monitoring simulation evolving into a lightweight digital twin foundation. - -## Flow - -1. Start the local stack: - - ```bash - docker compose up --build - ``` - -2. Open the dashboard: - - ```text - http://localhost:8090 - ``` - - This step is currently blocked in the production image. The page returns - HTTP 200 but stays blank because its Content Security Policy blocks the - React Router startup scripts. Do not present the later UI steps as verified - until a browser can render them. - -3. Confirm live telemetry: - - - wellhead pressure - - flow rate - - temperature - - water cut - - valve/pump status - -4. Inspect the current implemented dashboard: - - - fleet overview - - analytics charts - - per-wellhead detail page - - Alarm Center and active alarms on affected wellheads - -5. Inspect active alarms: - - - high pressure - - high temperature - - high water cut - - high vibration or degradation indicators - -6. Explain what is simulated: - - ```text - Readings are random values based on configured ranges. Pressure does not - cause flow to change. The two-minute and five-minute chart buckets and - alarm thresholds are chosen for a short demo, not a real plant. - ``` - -7. Explain the target what-if scenario: - - ```text - Close choke on WH-001 from 80% to 50%. - ``` - -8. Expected future behavior: - - - flow rate decreases - - tubing pressure rises - - forecast confidence may decrease if operating state becomes unstable - - alarms may trigger if thresholds are crossed - -9. Inspect the data layer: - - - raw readings in TimescaleDB - - alarm events - - future model state snapshots - - future forecast/scenario outputs - -10. Inspect architecture decisions: - - ```text - docs/adr/ - ``` - -## Current Status - -The overview, analytics, per-wellhead detail pages, Alarm Center, live telemetry, -and active alarm data are implemented. Stateful twin behavior, predictions, and -what-if scenarios are still target capabilities, not current features. +Show a reviewer how one synthetic well moves from live monitoring to a saved +what-if comparison. The short route is Overview, Monitoring, Scenarios, then +Saved runs. Allow roughly five minutes after the stack is ready. + +## Start and sign in + +Follow the root README to create the local environment, then run: + +```bash +docker compose up -d --build --wait +``` + +Open and sign in as demo@example.com with the documented +local password, demo-operator-2026. This USER account can save model analyses +but cannot change installed controls. + +The Guide in the main navigation explains each view and includes a first-run +walkthrough. Contextual help beside the forms explains their inputs. + +## Walk through one well + +1. In Overview, find WH-001. Read pressure, flow, water cut, model status, and + conditions. The Needs attention filter includes signals outside those three + headline readings. A configured well remains visible when it has no data. +2. Open the well's Monitoring view. Check readings, then expand Model + diagnostics to compare sample time with process time. The scheduler tick appears + separately because a ticking service does not prove a successful calculation. +3. Read Installed controls. Note the choke opening and revision before running + the scenario. This panel has no live command buttons. +4. Open Scenarios. Name the run Choke comparison, choose a five-minute duration + and a 60-second chart interval, and set the first change to minute zero. Select Choke + opening and enter 50 percent. If the current choke is already 50 percent, + choose a different target so the comparison shows a change. +5. Select Run scenario. The model runs from a copy of current state and saves + its result. Read the named saved result and its generation time on the right. +6. Compare Flow rate and Tubing pressure using the signal selector. The dashed + line holds the copied controls unchanged; the purple line follows the proposed + schedule. Each chart uses the signal's canonical unit. The difference is + scenario minus baseline at the same final sample. +7. Expand Original run inputs and the plotted-value table. Changing the form's + draft does not change these saved inputs. Confidence reasons explain the + model's limits. The heuristic score is not a probability. Threshold results + describe returned samples and can miss events between them. +8. Open Saved runs and reopen the named result. Refreshing that result's URL + reads saved points without running another scenario. Other authorized + dashboard readers can also read it. +9. Return to Monitoring and confirm the installed controls and revision are + unchanged. The synthetic process continues evolving while analysis runs. + +The Forecast view follows the same pattern with no control schedule. It holds +the copied controls constant. Both forms accept five to 120 minutes. Longer +runs reduce the model's heuristic confidence. + +## Explain the limits + +All 18 signals come from a synthetic model. Pressure, flow, and temperature +respond together; simpler diagnostic equations estimate gas, sand, corrosion, +annulus pressure, and vibration. No field measurements calibrated these +equations. Current trend buckets and alarm thresholds suit a short demo. + +The Alarm Center and monitoring charts remain available from the navigation. +The present operating point may have no active alarms. Do not claim alarms +exist when the page reports none. Projected threshold results do not create +live alarm events. + +A saved result may expire under retention while its history entry remains. +A timeout can leave the submission outcome unknown. Use Saved runs to look for +the result before submitting again. The application does not retry the POST. + +## Technical follow-up + +- Open the architecture overview for the model, Modbus, ingestion, and historian path. +- Open the Milestone 9 design for screen logic, failure handling, and verification. +- Open the API specifications for forecast, scenario, status, and history contracts. +- An OPERATIONS account can open Controls, save a proposal, review it and activate + it. Check the command outcome and running revision separately. An ADMIN account + uses Parameters to publish an override; existing models need a service restart. + The [operating workspace design](../design/operating-workspace.md) records both flows. + Field calibration and physical control remain outside this demo’s implemented scope. + +## Compare an earlier state + +Open Scenarios and select Saved snapshot. Search for a time in the displayed time +zone, choose a compatible candidate, and review its exact time. Name the run, +choose a control change, and run it. Check Starting state in the result, then +reload the link to confirm that it reopens the same saved branch. The model uses +alarm rules active at execution. Older branches can lose chart points sooner +because retention follows simulated dates. + +## Inspect a scheduled forecast + +Open Forecast and find Scheduled forecasts. Read whether it is enabled, its next +due time and its latest attempt. Compare that attempt with Latest completed result: +a failed new job does not make an older successful forecast current. Select +Open scheduled result to read the existing result. Check its saved time and +execution origin, then find the same entry in Saved runs. + +New installations have no enabled schedules. If there is no scheduled result, +explain that state or use a separate test installation for this longer walkthrough: + +1. Sign in as ADMIN and choose a test well with no enabled schedule. Open + Edit forecast schedule. Do not replace a schedule someone else is using. +2. Select Enable scheduled forecasts. Set Run every and Forecast ahead to five + minutes. Enter a reason, review the well and values, confirm, then Save schedule. + Allowed intervals are 5 to 1440 minutes; forecast duration is 5 to 30 minutes. +3. Wait for the displayed next due time, then inspect the attempt and saved result. + Saving starts a new interval. It does not run a forecast immediately. +4. Open the saved result and inspect its original inputs. The worker copies + current state and controls without changing the running well's controls. +5. Turn off the test schedule, enter the reason and confirm the save. A job + already running may finish. Saved results remain available subject to retention. + +If saving loses its reply, use Check saved schedule before another save. The page +does not retry the write automatically. The worker also skips missed intervals +instead of calculating every missed forecast. See the +[schedule design](../design/scheduled-forecasts.md) for authority and failure rules. + +## Read service health + +This optional walkthrough explains the software pipeline. Follow the +[metrics runbook](../../observability/README.md) to start the observability profile +and sign into Grafana with its separate local account. Open +Well simulation → Service health and allow about 30 seconds for initial samples. + +Compare Reachable with Ready. Reachable means the metrics endpoint answered; +Ready means the service reported that it could do its work. Read Time since last +commit alongside committed readings, then inspect model lag and API request time. +Expand Automation for scheduled-job and control outcomes. Open a panel's +Inspect → Data view to read its values as a table. + +Missing samples remain gaps or Unknown. An empty failure series does not prove +that no failures occurred. Check the time range and reachability first. Return to +the well dashboard for readings, saved analyses and operating actions. + +## Inspect and acknowledge an alarm + +Open Alarm Center and choose a well. Needs acknowledgement can include an event +whose condition already returned to normal. All active counts every open +condition, including acknowledged and shelved ones. Expand the help section +for those distinctions and the event details for its exact identity. + +With an OPERATIONS account, select Review action, read the target and effect, +enter a reason and confirm. Acknowledgement records recognition; normal readings +clear the condition. Shelve rule sets a temporary deadline for that well and +rule, including later events before the deadline. Shelved rules shows the reason +and deadline even when no event is active. End shelf now returns unacknowledged +events to the attention view. If a reply is lost, use Check result before any +explicit retry. Do not create an alarm just to populate the presentation. diff --git a/docs/design/alarm-lifecycle.md b/docs/design/alarm-lifecycle.md new file mode 100644 index 0000000..29f8808 --- /dev/null +++ b/docs/design/alarm-lifecycle.md @@ -0,0 +1,278 @@ +# Alarm acknowledgement and timed shelving + +Date: 2026-10-17 +Status: Implemented and verified locally +Decision: [ADR 0039](../adr/0039-alarm-operator-actions.md) + +## Scope and system design + +Extend the Alarm Center with acknowledgement, timed shelving, early unshelving, +operator reasons and action history. Preserve the current SQL evaluator, its +three-breach/three-clear seeded behavior, threshold definitions and active totals. +The lifecycle changes no reading, rule, model control or physical equipment. + +```mermaid +flowchart LR + R[Historian readings] --> E[Existing alarm evaluator] + E --> A[(Alarm events: condition truth)] + O[OPERATIONS review and confirmation] --> API[Authenticated API] + API --> T[One bounded transaction] + T --> K[(Exact event acknowledgement)] + T --> S[(Well and rule shelf revision)] + T --> H[(Action receipt and audit)] + A --> V[Alarm Center views] + K --> V + S --> V + H --> V +``` + +## State and logic + +Use three independent states: + +- Condition: active when `alarmEvent.cleared_at IS NULL`, otherwise returned to normal. +- Acknowledgement: present only for that event's exact bigint ID and trigger time. +- Shelf: active when `shelved_until > observed_at`, where `observed_at` is the + database time for the read. Equality means expired. + +`needsAcknowledgement = !acknowledged && !shelved`. It includes retained events +that cleared before acknowledgement. `activeTotal` depends only on condition +state. Retriggering creates a new event that needs its own acknowledgement; +a well/rule shelf still applies until its deadline. Expiry needs no background job. + +Events currently have 90-day retention. The list covers retained rows and does +not imply a permanent event archive. General audit events also have 90-day +retention. New receipts and acknowledgement metadata remain durable; they contain +only action context, not copies of telemetry histories. Their future archival +policy must preserve receipt and current-state references. + +## Persistence and exact identity + +Append three ordinary tables to `data/sql/schema.sql` and regenerate its catalog +contract from a fresh database. Use the existing verified `extend` command with +copies of the immediately preceding source and contract. Test a restored backup +before extending the main database. Do not create numbered migration files. + +| Table | Identity | Stored meaning | +| --- | --- | --- | +| `alarm_action` | UUID, unique actor/request key | Immutable input hash, input, actor name/ID, timestamp and confirmed result | +| `alarm_acknowledgement` | Event bigint and exact trigger time | Acknowledged time and receipt ID | +| `alarm_shelf` | Well and rule | Latest deadline, revision, updated time and receipt ID | + +Acknowledgement and receipt records deliberately have no foreign key to the alarm +hypertable. Shelves reference existing wells/rules. Current state references its +immutable action receipt. The application role can read/insert receipts and +acknowledgements, but cannot update/delete them. Shelf updates have narrow grants. +It receives no new UPDATE grant on alarm events, rules or wells. + +Extract the existing bigint/UTC-microsecond validators into a shared identity +module and preserve the snapshot exports as aliases. New alarm responses encode +IDs as decimal strings and UTC timestamps with six fractional digits. SQL casts +and `to_char` preserve identity before the driver can round it. Format times for +people only after storing the original strings used for requests. + +## API and transaction boundary + +All reads require `dashboard:read`. Writes require `alarm:acknowledge`, a maximum +4KB JSON body and the existing ten-per-user-per-minute command budget. + +- `GET /alarms`: summary totals plus 50 event rows, filter `attention`, `active` + or `history`, optional well ID and bounded cursor. Attention/active order by + severity, then oldest trigger time and exact ID; history uses newest time/ID. +- `GET /alarms/shelves`: current shelves, 50 per page, with well/rule cursor. + It includes shelves whose condition has returned to normal. +- `GET /alarms/events/:eventId?triggeredAt=...`: current exact event, its current + shelf and the latest 20 actions for that well/rule. A retained event may clear + without losing its acknowledgement. Missing/expired identity returns 404. +- `POST /alarms/actions`: one strict acknowledgement/shelve/unshelve input, + explicit confirmation, reason (1–500 characters) and UUID Idempotency-Key. +- `GET /alarms/actions/:requestKey`: the signed-in actor's committed receipt, + or 404. Never expose another actor's request key lookup. + +Both list responses also carry the reference fleet’s well names for the filter +and compute their summary within the same read transaction. + +Each write starts with a 500ms lock timeout and 3s statement timeout. Lock/recheck +the current user first, using the existing transaction authority helper. Check +for an existing own receipt; matching input returns the original result, and a +different input returns 409. An acknowledgement requires an existing retained +exact event and fails with 409 if another action already acknowledged it. It can +acknowledge an active or returned-to-normal event. + +Shelf writes require the last observed revision (zero if absent). Lock an existing +shelf row, validate the expected revision, then calculate the new deadline from +the database clock after acquiring locks. The initial insert's unique key closes +the absent-row race. Unshelving requires an unexpired shelf. A changed revision +or expiry returns 409 and requires another read/review. Replace a shelf only with +a new explicit action and reason; there is no silent renewal. + +Insert the immutable receipt, mutate lifecycle state and insert the audit event +in the same transaction. An exception rolls back all three. Log the request ID +and error code without input reasons or credentials. Database/transport failure +returns 503 with an uncertain outcome: the browser must look up the same key. +A retry must preserve that key and exact reviewed input. No automatic write retry. + +Read the summary and list within one read-only repeatable-read transaction so +counts and rows share a snapshot and database observation time. Cursor pages are +live views; new state may move a row between views. Use First page to return to the current start of the list. Refresh updates +the page being read. Keep at most one page in the browser, with Next and First page controls. +Never infer totals from the visible page. + +## Screen design and copy + +The Alarm Center opens at **Needs acknowledgement**. Keep four summary tiles: +all active, active critical, needs acknowledgement and current shelves. Explain +that active includes acknowledged and shelved conditions. Tabs expose Needs +acknowledgement, All active, Shelved rules and History. A well filter narrows each +view. History shows retained events and their process/acknowledgement states. + +Use readable cards with well, severity, parameter, trigger value and threshold, +condition status, acknowledgement and shelving state. Label the threshold as +**Current rule threshold**: the event stores its trigger value and severity, but +not a historical copy of the rule threshold. Show the rule’s current breach/clear +counts instead of assuming every rule always uses the seeded count of three. Put timestamps and actor +reasons in expandable details. Show the event's precise identity only in those +details. Open well remains a normal link. An explicit Review action opens one +editor with its target, action, reason, duration when relevant and confirmation. +The default shelf duration is 30 minutes; accept 5–480 whole minutes. Label the +final buttons Acknowledge event, Shelve rule or End shelf now. + +A reader sees the same evidence and the explanation that OPERATIONS performs +these actions. ADMIN also gets that explanation. Never show a working write form +based only on a stale role: the server rechecks authority at the transaction. + +Retain the last successful read with its timestamp on refresh failure. Disable +write forms after a failed read or after 20 seconds without a successful refresh. +Keep typed reasons during background refresh. A revision change requires explicit +review of the new state; do not adopt it silently. A lost write reply freezes the +reviewed input and offers Check result. A confirmed receipt closes the editor, +announces the result, refreshes the list and moves focus to the outcome message. +A conflict keeps the reason but requires a fresh target review. Permission loss +clears the operating view through the existing route error boundary. + +Use the shared spacing, typography, blue actions and named severity badges. +Cards stack at 320px; labels wrap and interactive targets retain 44px height. +Native details elements provide keyboard-accessible extra context. A short help +section explains that acknowledgement records recognition, normal readings clear +the condition, and shelving temporarily removes a rule from the attention view. +Shelved rules remain visible in their own list and in all-active totals. + +## Design checks before implementation + +Check 1 traced evaluator SQL, event retention, exact snapshot identity handling, +capability separation, audit grants, account-first locking, retained-read behavior +and command rate limits. It found two necessary additions: retain cleared but +unacknowledged events in the attention view, and list shelves independently of +active events. Reusing rounded legacy overview IDs for writes is excluded. + +Check 2 passed 84 screen/state/role/viewport combinations at 1440, 390 and 320px, +with no horizontal overflow or browser errors. It included a shelf without an +active event, disabled write controls, explicit uncertainty recovery and role +restrictions. Sixteen condition/acknowledgement/deadline cases passed, including +exact expiry and returned-to-normal events. The identity check demonstrated why +a bigint above JavaScript's safe range must stay a string. + +The concurrency review confirmed account-first locking, a unique insert for the +absent-shelf race, revision checks after row locks and one transaction for receipt, +state and audit. It also required current-threshold wording because the original +threshold is not stored in the event. The first prototype assertion checked the +fieldset rather than its disabled controls; that harness check was corrected. +The prototype now preserves its event handlers when adding the help section. + +## Verification plan + +Test exact bigint/microsecond identity, role separation, current session revocation, +input limits, CSRF, idempotent replay/payload conflict, competing acknowledgement, +shelf revision races, expiry and retrigger behavior. Inject audit failure and prove +all lifecycle writes roll back. Use the actual restricted API role and verify its +immutable-table grants. Test paging, summary consistency and action history. + +Verify the schema on a fresh database and on a restored copy of the current main +database, comparing existing record counts/fingerprints. Extend the isolated +installation, then test real browser actions, failure recovery, stale drafts, +permission loss, phone layout and accessibility. Review and fix findings before +main extension, local deployment and commit. Keep the physical sleep/wake and +remote publication gates separate from these local results. + +## Review finding: restored forecast checks, 2026-10-17 + +The first full restore reached the structural gate and stopped before extension. +The main database matched its old contract. A temporary-table reproduction found +four forecast duration checks with different parentheses: PostgreSQL expands +`BETWEEN` into a nested `AND` during initial creation, then flattens the `AND` +when parsing the expression written by `pg_dump`. Both retain the same bounds +and whole-minute divisibility test. + +The first review compared the original and restored definitions on the pinned +engine and confirmed validation flags. The second limited the remedy to an +exact whole-expression match for `interval_seconds` and `horizon_seconds`, with +numeric bounds, modulo and equality. It preserves every operand and rejects +other shapes. Unit tests must still detect changed bounds, divisors, remainders, +operators, columns and validation flags; a database test covers the actual +round trip and all 27 true/false/null combinations for AND associativity. +The canonical SQL and its recorded checksum remain unchanged by this fix. + +The browser review also exposed unstable implicit label lookup after a textarea +or select rerender. The editor and well filter now use explicit labels, and the +duration explanation uses `aria-describedby`. Closing a review returns focus to +its originating button; each receipt has its own identity so repeated actions +still move focus to their confirmation. + +Rendered inspection caught a missing shared form class that automated interaction +checks did not flag. The editor now uses the established form styles, explicit +labels and a three-line reason field. Summary tiles stack below the small-screen +breakpoint to avoid splitting long labels. The browser regression also checks +that the action, reason and well controls keep a 44px minimum height. + +The parallel full-restore attempt failed while loading TimescaleDB catalog +foreign keys. It affected only its disposable database. The full rehearsal uses +serial `pg_restore`, as required by the [official restore guide](https://github.com/timescale/Tiger-Data-Docs/blob/main/src/content/docs/deploy/self-hosted/backup-and-restore/logical-backup.mdx). +Do not add `-j` or `--jobs` to this full-database restore procedure. + +Rendered implementation examples use an explicit synthetic browser-test fixture: +[desktop review](../screenshots/alarm-review-desktop.png) and +[phone review](../screenshots/alarm-review-mobile.png). + +## Verification on 2026-10-17 + +- Both design checks passed before implementation: 84 rendered combinations and + 16 state cases, followed by the documented concurrency and identity review. +- All 127 API integration tests passed against the restricted application role. + The 17 new lifecycle cases cover exact identities, retained cleared events, + role/session checks, idempotency, concurrent acknowledgement and shelf edits, + expiry/retrigger behavior, pagination and audit rollback for inserts and updates. + The original alarm-count and analytics regression tests remain in the suite. +- The shared-contract and frontend suites passed 40 tests. The schema unit and + comparison suites passed 60 tests. Forty-one database lifecycle cases passed; + the new parser round-trip case passed separately after correcting its fixture + to use the provided connection. No database cases were skipped. +- The alarm and general usability browser workflows passed. Fault injection + covered retained drafts, explicit current-state review, lost replies, same-key + retries and permission loss. The alarm workflow checked keyboard focus, 44px + controls and layouts at 1440, 390 and 320px, with no horizontal overflow or + unexpected browser errors. Automated WCAG 2 A/AA, 2.1 AA and 2.2 AA checks + reported no violations. This is not a claim of full assistive-technology coverage. +- A real isolated OPERATIONS session acknowledged an exact bigint/microsecond + event without clearing it. A deliberately lost committed reply resolved by + receipt lookup without another POST. A shelf survived clear/retrigger and ended + early, producing three receipts while all installed controls stayed unchanged. +- The serial full-backup restore and alarm extension preserved counts and + fingerprints for 3,657,848 records across 26 existing tables. The private + archive is 86,552,890 bytes. The disposable restore database and temporary + container copy were removed; the original private archive remains. +- Type checks, lint, formatting, OpenAPI validation and prose checks passed. + Initial failures included an undeclared build dependency, a timestamp-expression + precedence error, test fixture errors, implicit labels and a missing form style. + Review fixed those findings. The first concurrent API run timed out under + restore load; the complete rerun passed after that load ended. + +Remote CI and publication have not run. Physical host sleep/wake remains deferred +by the owner. The reference model remains synthetic and has no field calibration. + +The main database extension then passed without replaying seeds. The deployed +API and dashboard passed all four alarm views using the read-only demo account, +including phone layout and browser-error checks. All eight Compose services are +healthy, all four Prometheus targets are up, and the authenticated provisioned +Grafana dashboard remains available. The fingerprint of the installed control +state for all 12 wells is unchanged. Prior API/dashboard images remain tagged +`before-alarms` locally; the database extension only adds the lifecycle tables. diff --git a/docs/design/alarm-lifecycle/index.html b/docs/design/alarm-lifecycle/index.html new file mode 100644 index 0000000..5cbf145 --- /dev/null +++ b/docs/design/alarm-lifecycle/index.html @@ -0,0 +1,133 @@ + + + + + + Alarm lifecycle design + + + + + + +
+ Alarm lifecycle designIllustrative data +
+
+ + diff --git a/docs/design/alarm-lifecycle/screens.js b/docs/design/alarm-lifecycle/screens.js new file mode 100644 index 0000000..be72b48 --- /dev/null +++ b/docs/design/alarm-lifecycle/screens.js @@ -0,0 +1,86 @@ +const role = document.querySelector("#role"), + state = document.querySelector("#state"), + workspace = document.querySelector("#workspace"); +function render() { + const s = state.value, + operator = role.value === "operator", + disabled = [ + "pending", + "conflict", + "uncertain", + "stale", + "error", + "expired", + ].includes(s); + const heading = + "

Alarm Center

Check the condition, record that you have seen it, and follow your operating procedure.

"; + if (s === "permission") { + workspace.innerHTML = + heading + + '

Access denied

Your account no longer has access to this view. Reload after an administrator reviews your access.

'; + return; + } + if (s === "loading") { + workspace.innerHTML = heading + '

Loading alarm state…

'; + return; + } + workspace.innerHTML = + heading + + `
${[ + ["All active", s === "empty" ? 0 : 4], + ["Active critical", s === "empty" ? 0 : 1], + ["Needs acknowledgement", s === "empty" ? 0 : 3], + ["Current shelves", ["shelved", "shelved_clear"].includes(s) ? 1 : 0], + ] + .map( + ([k, v]) => + `
${k}${v}
`, + ) + .join( + "", + )}

Active totals include acknowledged and shelved conditions. Needs acknowledgement also includes retained events that returned to normal.

`; + if (["stale", "error"].includes(s)) + workspace.innerHTML += + ''; + if (s === "empty") { + workspace.innerHTML += + '

No events need acknowledgement

Open All active to check conditions that remain active.

'; + return; + } + workspace.innerHTML += + s === "shelved_clear" + ? '
Shelved rule

Wellhead 1 · Tubing pressure

No active event. The shelf still applies to new events until 11:30.

Operator1: instrument inspection. Shelf revision 2.

' + : `
CRITICAL${s === "returned" ? "Returned to normal" : "Active condition"}${s === "acknowledged" ? "Acknowledged by operator1" : "Not acknowledged"}${s === "shelved" ? 'Shelved until 11:30' : ""}${s === "expired" ? 'Shelf deadline reached. Refresh to verify.' : ""}

Wellhead 1 · Tubing pressure

Value when triggered
3200 psi
Current threshold
> 3000 psi
Triggered
17 Oct, 10:42
Well location
North field

Open the well for current readings. This value describes the event that opened the alarm.

Event details and recent actions

Event 9007199254740993, 2026-10-17T09:42:00.123456Z.

Operator1 shelved this rule for instrument inspection. Previous acknowledgement belongs to a different event.

Open well${operator ? '' : "

OPERATIONS can acknowledge events and manage shelves. Your account can inspect their history.

"}
`; + if (operator) { + workspace.innerHTML += `

Review action for Wellhead 1

${s === "conflict" ? '

Another operator changed this state. Your reason remains below. Refresh and review the current state before submitting.

' : ""}${s === "uncertain" ? '

The reply did not confirm the outcome. Check this request before trying another action.

' : ""}
Acknowledge this event

This records that you have seen the event. Normal readings clear the condition.

A shelf applies to this well and rule, including new events, until its deadline. Allowed duration: 5–480 minutes.

`; + workspace + .querySelector("form") + .addEventListener("submit", (e) => e.preventDefault()); + const form = workspace.querySelector("form"), + pick = form.querySelector("select"), + duration = form.querySelector("input[type=number]").closest("label"), + submit = form.querySelector("button"); + if (["shelved", "shelved_clear"].includes(s)) pick.value = "End shelf now"; + if (s === "acknowledged") pick.value = "Shelve rule"; + const chosen = () => { + form.querySelector("p").textContent = + pick.value === "Acknowledge event" + ? "This records that you have seen the event. Normal readings clear the condition." + : pick.value === "Shelve rule" + ? "This temporarily removes the well and rule from Needs acknowledgement, including new events." + : "This ends the shelf now. Unacknowledged events return to Needs acknowledgement."; + duration.hidden = pick.value !== "Shelve rule"; + form.querySelector("legend").textContent = pick.value; + submit.textContent = s === "pending" ? "Saving action…" : pick.value; + }; + pick.addEventListener("change", chosen); + chosen(); + } + workspace.insertAdjacentHTML( + "beforeend", + '
How acknowledgement and shelving work

Acknowledgement records recognition. Three consecutive normal readings clear the seeded condition. Shelving temporarily removes this well and rule from Needs acknowledgement. All active totals still count it, and Shelved rules shows its deadline and reason.

', + ); +} +role.addEventListener("change", render); +state.addEventListener("change", render); +render(); diff --git a/docs/design/consolidated-schema.md b/docs/design/consolidated-schema.md new file mode 100644 index 0000000..b9df175 --- /dev/null +++ b/docs/design/consolidated-schema.md @@ -0,0 +1,392 @@ +# One current database schema + +Status: implemented and adopted on the local database, 2026-10-13. + +The engine versions and initial recovery result below describe that release. +The [2026-10-16 engine upgrade](database-engine-upgrade.md) records the subsequent +PostgreSQL conversion, fixed logical restore, and explicit engine acceptance. + +The owner selected one SQL schema file on 2026-10-13. The maintained source will +be data/sql/schema.sql. The owner then authorized implementation. + +## What this resolves + +The owner asked on 2026-10-03 to combine the database definitions into one file +and asked whether the file must be SQL. The follow-up on 2026-10-05 asked for +more explanation. That discussion did not settle the implementation. + +The intended result is one readable definition of the complete current schema. +The owner should not need to mentally replay a sequence of corrections to find +the current columns, constraints, or policies. + +This implementation replaces the numbered schema files as the maintained source. +Git retains their history. A generated overview beside an unchanged migration +chain would not satisfy this request. + +## What was checked + +At commit ec85ac5, data/sql/migrations contains 13 SQL files and 928 lines. +The live database records all 13 with matching checksums. Its two seed files +bring the existing schema_migrations ledger to 15 records. + +The database has 23 application tables, seven TimescaleDB hypertables, four +continuous aggregates, and two ordinary application views. A hypertable holds +time-series data in chunks. A continuous aggregate stores automatically +refreshed summaries. The installed TimescaleDB version is 2.19.3. + +The former Python runner locked each applied file to its checksum. It could not +simply load a replacement file against the existing database. Some old scripts +also drop and recreate views. Replaying those scripts would be the wrong +operation for a database whose structure is already current. + +## Format options + +| Option | What the owner maintains | Benefit | Cost | +| --- | --- | --- | --- | +| One schema.sql file, chosen | The complete current SQL definitions | Uses the database's own language and preserves existing TimescaleDB definitions | SQL remains part of the project | +| One Python schema module | Python definitions plus SQL for database-specific features | Keeps the file in the service's language | Adds a representation to learn; function bodies and TimescaleDB calls still need SQL | +| One TypeScript schema module | Drizzle definitions plus database-specific SQL | Places definitions beside the API's typed access layer | Requires checking coverage for domains, triggers, functions, aggregates, and policies | +| Desired-state comparison tool | One schema plus tool configuration | Can inspect an existing database and propose changes | Must first prove coverage for every TimescaleDB object this project uses | + +The extension does not decide whether changes are safe. A Python or TypeScript +file can still contain a chain of historical changes. A SQL file can describe +the complete current state without such a chain. + +Atlas documents a workflow that compares a desired schema with an existing +database and prepares changes. Its complete support for this project's +TimescaleDB objects has not been verified. It is therefore a separate option +to investigate, not a dependency of this implementation. +See [Atlas declarative apply](https://atlasgo.io/declarative/apply). + +## Recommended layout + +```text +data/sql/schema.sql Complete current database structure +data/sql/schema-contract.json Generated structure and legacy checksums +data/sql/seeds/ Demo records, separate from structure +data/src/schema.py Command entry point and service provisioning +data/src/schema_install.py Installation, adoption, history, and locking +data/src/schema_catalog.py Database structure comparison +data/src/schema_sql.py SQL loading and statement splitting +monorepo/packages/db/ Typed application access, checked against SQL +``` + +Schema means structure: tables, columns, rules, functions, and database policies. +Seed data means example records, such as the demo wells and reviewer account. +Keeping seeds separate lets a database install its structure without inventing +demo assets. Password generation and environment-specific grants remain in +Python, where the existing service provisioning already lives. + +The schema file groups current definitions by purpose: + +1. Extension, enums, and the model-version domain. +2. Accounts, assets, parameter metadata, readings, and alarms. +3. Twin snapshots, recovery records, parameter overrides, and projections. +4. Control commands, active controls, events, and parameter proposals. +5. Functions, triggers, indexes, and ordinary views. +6. Hypertables, continuous aggregates, refresh, compression, and retention. + +The actual statement order must also respect dependencies. For example, a +hypertable must exist before its continuous aggregate or compression policy. + +Fold session_version, reading source_kind, reading model_version, and alarm +counters into their final table definitions. Include active_controls_restored +in the original recovery-event constraint. Remove obsolete view drops and +superseded definitions from the new installation script. + +Keep model_version_text as a domain. The existing compressed historian depends +on that choice, as explained in ADR 0036. This consolidation does not redesign +column types, public responses, model behavior, or service permissions. + +## Installer logic + +The command modes are init, check, adopt, and build-reference. The default is +init. It installs an empty application schema or checks an adopted database. +A legacy database needs an explicit adopt command. check never applies schema, +seed records, accounts, or grants. + +build-reference requires an empty application schema and --contract-output. +It installs schema.sql and captures the actual catalog into a comparison record. +The record contains the SQL checksum, PostgreSQL major version, TimescaleDB +version, expected structure, and immutable legacy file checksums. It is generated, +not edited alongside the SQL. It contains no application rows or credentials. + +At consolidation, Compose and CI pinned the verified PostgreSQL 14 / +TimescaleDB 2.19.3 image by digest. The engine upgrade design records the current pin. +The installer rejects other versions before a normal fresh installation. +Schema tests rebuild from the SQL and compare against the generated record. + +```mermaid +flowchart TD + A[Read canonical schema] --> B[Inspect database] + B --> C{Database state} + C -->|Empty| D[Install schema and verify] + C -->|Current legacy installation| E[Check history and compare structure] + E --> F[Record baseline adoption] + C -->|Already adopted| G[Check source identity and structure] + C -->|Older, partial, or unexpected| H[Report differences and stop] + D --> I[Apply configured seeds and service grants] + F --> I + G --> I +``` + +### New database + +Install the canonical file only after proving that application objects are +absent. Use a database advisory lock so two installers cannot run together. +An advisory lock is a named lock used by cooperating application processes. +Apply statements in dependency order, respecting operations that cannot share +a transaction, then verify the resulting structure before marking it ready. + +The schema_installation table records the source and reference checksums, actor, +method, completed statement count, and start and completion times. A partial +installation remains incomplete and normal retries stop. It has no automatic +resume or cleanup command. Inspect progress before restoring a backup or +explicitly rebuilding an expendable database. A crash between a statement and +its progress update requires inspecting the database, not assuming the statement +failed. + +### Existing database + +For an installation older than today's schema, first use the +[version-matched update sequence](../operations/database-updates.md). The original +legacy adoption uses `cd09691`; later engine acceptance and table extensions are +separate steps. The rules below describe adoption of matching structure. + +Adopt a fully current legacy installation without replaying its schema SQL: + +1. Take a backup and verify the backup procedure on a disposable database. +2. Verify every expected legacy filename and checksum. Reject an incomplete, + modified, or unknown history. +3. Compare the actual application structure with the generated reference from + a fresh installation on the supported PostgreSQL and TimescaleDB versions. +4. Check service privileges separately using the configured role names. The CLI + reprovisions those grants transactionally after schema verification. +5. Record the canonical source checksum and verified baseline identity in a + transaction. Keep the old schema_migrations records unchanged as history. +6. Run the application checks and confirm existing readings and controls remain. + +Reference generation is a release operation on a disposable database. Ordinary +startup compares catalog definitions with that record and does not require +CREATEDB privileges or another database. + +A checksum proves which file was used. It does not prove the actual database +still matches that file. The structural comparison must include column types, +defaults, nullability, keys, constraints, indexes, functions, triggers, views, +hypertable dimensions, aggregate definitions, compression settings, and policies. + +Exclude data values and generated identifiers from that comparison. Internal +chunk IDs and job IDs can differ between equivalent installations. Match those +objects by their application meaning, such as the hypertable or aggregate name. +Tests alter the dimension interval, compression order, aggregate mode, and job +schedule to prove those differences remain visible. Generic catalog checks also +cover enum and domain rules, sequence definitions, row security, and public +function execution privileges. Service-specific grants remain a separate check. + +### Ordinary restart + +Check the recorded canonical identity and required structure. An unchanged +database needs no schema operations. Seed and account setup keep their existing +separate rules. Schema mismatch must produce an actionable error before dependent +services start, without deleting or recreating application objects. + +## What happens when the schema changes later + +One source file removes the need to reconstruct the current design from old +patches. It does not remove the work of converting an existing database. + +For example, changing a column name in a definition does not tell a tool whether +to preserve old values under a new name or discard them. Renames, removals, +conversions, and changes to aggregates need a reviewed operation for existing data. + +This implementation provides consolidation, fresh installation, +verification, and adoption. It does not include a general automatic schema-diff +engine. Editing schema.sql alone will not automatically upgrade an older database. +A later change must include an explicit, tested conversion plan when preserving +an existing database. The operational record must identify the before and after +schema and the operation that was applied. A disposable development database +can instead be rebuilt when its owner explicitly chooses to discard its data. + +Do not silently reintroduce a numbered corrective-file chain as the default +authoring workflow. Keep the current schema authoritative and explain the +existing-data conversion alongside the feature that needs it. + +CREATE TABLE IF NOT EXISTS only suppresses a name collision. PostgreSQL does +not guarantee that the existing table matches the requested definition. +See [PostgreSQL 14 CREATE TABLE](https://www.postgresql.org/docs/14/sql-createtable.html). + +## Failure behavior + +| Condition | Required result | +| --- | --- | +| Database unavailable | Fail startup with a safe connection error | +| Another installer holds the lock | Wait for a bounded interval, then report the conflict | +| Legacy checksum differs | Refuse adoption; show the filename without changing the database | +| Ledger matches but structure differs | Refuse adoption and report the actual structural difference | +| Installation stops partway | Keep incomplete status and inspect before retry | +| Existing schema differs from the new source | Require a reviewed conversion or explicit disposable rebuild | +| Aggregate or policy comparison is unsupported | Fail the check; do not treat an unchecked object as matching | +| An operation would remove data | Require a separate explicit decision showing what would be lost | + +## Release verification order + +1. Produce the consolidated current-state SQL and a section-by-section review. +2. Build a disposable database from it and compare it with the current schema. +3. Implement init, check, and adopt with locking, clear state, and failure handling. +4. Test adoption against a restored copy of the populated existing database. +5. Test changed checksums, structural drift, partial installation, concurrent + installers, repeat startup, and mismatched TimescaleDB settings. +6. Update the Drizzle contract tests and API test harness to install the same + canonical schema as Compose. Run the complete API and Python gates. +7. Verify alarms, telemetry, snapshots, projections, controls, account revocation, + retention, and service grants after adoption. Compare records before and after. +8. Replace the old schema directory, update operational guides, and rebuild + containers. Adopt the original local database only after the copy passes. + +Schema consolidation does not require deleting the current Docker volume. +Deleting source files in a new commit preserves Git history; rewriting existing +commits is unnecessary. This design does not authorize a new commit or push. + +## Verification record + +The first disposable installation matched the live database's checked structure. +All 13 legacy checksums matched before adoption work began. The automated schema +suite covers fresh installation, repeat startup, adoption, incomplete history, +changed checksums, actual structural drift, concurrent installers, and an +interrupted installation. It is included in the database CI job. + +Completed release checks: + +- Schema suite: 59 passed, including 28 tests against disposable databases. +- Full Python suite: 234 passed, 41 skipped because they require database opt-ins. + All 41 were run separately: 28 schema cases and 13 control/projection cases. +- API integration suite: 89 passed with the restricted API database role. +- Ruff lint and formatting, strict mypy, changed TypeScript formatting, prose, + and Git whitespace checks passed. The installer container built successfully. +- Physical backup: pg_verifybackup passed. An isolated server recovered from a + copy using the same database image. +- Recovered-copy adoption preserved counts and content fingerprints for all + 23 application tables, containing 9,308,932 rows, plus all 15 legacy ledger rows. +- Live adoption exited successfully. Application object identities, accounts, + controls, legacy history, and historical reading samples stayed unchanged. + No application table's row count fell during the check. +- Normal installer startup and the read-only check both exited successfully. +- All six long-lived services stayed healthy. Twin-core reported 12 running + wells; ingestion reported 216 mappings. The API and dashboard returned HTTP 200. +- Fresh connections with the live API and twin credentials read current model + telemetry. Checks confirmed their restricted grants and absence of schema + creation and temporary-table privileges. +- Disposable test databases, roles, and the recovery server were removed. + The verified physical backup remains available. + +The first sandboxed Python run could not open local HTTP sockets; it passed +with local network access enabled. Test setup was corrected for masked connection +passwords and inherited TimescaleDB extensions. An extra test found generated +IDs in internal trigger definitions. The comparison now resolves those IDs to +table names and still rejects a trigger pointing at the wrong hypertable. + +The verified physical backup is retained in Docker volume +well_schema_physical_backup_20261013. The original live volume was preserved. +No commit or push was made during adoption. The consolidation was later +committed locally as cd09691 on 2026-10-16. + +### Recovery finding: compressed null columns + +A logical backup completed, but restoring it on TimescaleDB 2.19.3 failed in a +compressed chunk's model_version column. The error reported a null return while +reading the compressed value Bg==. This matches +[TimescaleDB issue 8893](https://github.com/timescale/timescaledb/issues/8893). +The maintainer identifies 2.20.0 as containing the fix. The failed logical +restore is not evidence of a recoverable backup. + +For this release, use a physical backup of the database cluster. It preserves +the database files and the write-ahead log, which PostgreSQL uses for recovery. +pg_basebackup creates it while the server runs; pg_verifybackup checks its +manifest and required log records. Start a separate server from a copy of the +verified backup before adopting the live database. Keep the original backup +unchanged. See [pg_basebackup](https://www.postgresql.org/docs/14/app-pgbasebackup.html) +and [pg_verifybackup](https://www.postgresql.org/docs/14/app-pgverifybackup.html). + +The local verification uses a dedicated Docker volume for the physical backup +and another for the recovered copy. The recovery server binds only to the host's +loopback interface. It runs the same image, with background jobs disabled during +record comparisons. No application service connects to that copy. + +### Repeating the physical backup check + +These commands use a new volume name and the running database's exact image. +The backup client shares its network namespace so the connection uses the +existing loopback replication rule. The selected environment value is the +username, not its password. + +```sh +schema_backup_volume="well_schema_backup_$(date -u +%Y%m%dT%H%M%SZ)" +schema_recovery_volume="${schema_backup_volume}_check" +schema_db_image=$(docker inspect --format '{{.Image}}' wellhead_db) +schema_db_user=$(docker exec wellhead_db printenv POSTGRES_USER) + +docker volume create "$schema_backup_volume" +docker run --rm --network container:wellhead_db \ + --mount "type=volume,source=$schema_backup_volume,target=/backup" \ + "$schema_db_image" pg_basebackup -h 127.0.0.1 -U "$schema_db_user" \ + -D /backup -X stream -c fast --manifest-checksums=SHA256 + +docker run --rm --network none \ + --mount "type=volume,source=$schema_backup_volume,target=/backup,readonly" \ + "$schema_db_image" pg_verifybackup /backup + +docker volume create "$schema_recovery_volume" +docker run --rm --network none \ + --mount "type=volume,source=$schema_backup_volume,target=/backup,readonly" \ + --mount "type=volume,source=$schema_recovery_volume,target=/restore" \ + "$schema_db_image" sh -c 'cp -a /backup/. /restore/' +docker run -d --name "$schema_recovery_volume" -p 127.0.0.1:55440:5432 \ + --mount "type=volume,source=$schema_recovery_volume,target=/var/lib/postgresql/data" \ + "$schema_db_image" -c timescaledb.max_background_workers=0 -c autovacuum=off +``` + +Wait for the recovered server to accept connections. Use the configured database +credentials with host 127.0.0.1 and port 55440 to verify structure and stored +records on that copy. It has no application writers. Keep jobs disabled during +the before-and-after adoption comparison. For a recovered database that already +has the current baseline, run check instead of adopting again. + +After verification, stop and remove that named recovery container and its copy +volume. Retain the verified backup volume separately. Do not start the backup +volume itself as a running server, because recovery and later writes change it. + +A physical backup covers the whole PostgreSQL cluster, including its roles and +other databases. Protect it like the live volume. Its recovery requires matching +PostgreSQL and TimescaleDB binaries. A separately tested engine upgrade was +required before relying on logical restores of the affected compressed data. +The [2026-10-16 upgrade](database-engine-upgrade.md) completed that verification. +For a retained PostgreSQL 14 backup, use its original image and the matching +installer/reference from cd09691 first. Convert that recovered copy before +checking it against the current PostgreSQL 16 reference. + +## Related + +- [ADR 0006](../adr/0006-schema-management.md): original schema ownership decision and the single-file amendment. +- [ADR 0036](../adr/0036-model-telemetry-through-modbus.md): reading source and model-version compatibility. +- [8B design](twin-core-milestone-8b.md): existing controls and privileges that consolidation must preserve. + +## Adding new ordinary tables + +The `extend` mode applies reviewed additions from the same canonical source. +Before changing the schema, save its installed commit's SQL and contract as +private temporary reference files. Generate the target contract from a fresh +reference database. Verify a backup restore, then run: + +```sh +python data/src/schema.py extend \ + --previous-schema /private/previous-schema.sql \ + --previous-contract /private/previous-schema-contract.json \ + --without-seeds +``` + +Use the installed database's environment and the matching Python dependencies. +The command checks the old installation, adds only new ordinary tables and their +indexes, verifies the complete target and audits the identity change. It then +provisions service grants. It never infers changes to an existing table. A normal +startup refuses an old installation until this explicit operation succeeds. +See [allowed changes and failure behavior](schema-extension.md). diff --git a/docs/design/dashboard-usability.md b/docs/design/dashboard-usability.md new file mode 100644 index 0000000..72920f4 --- /dev/null +++ b/docs/design/dashboard-usability.md @@ -0,0 +1,184 @@ +# Dashboard usability and delivery + +Date: 2026-10-17 + +## Purpose + +Help a new user inspect a well, understand its readings, and compare a proposed +change without confusing a saved calculation with the running model. +The owner requested design, two design checks, implementation, tests, review, +and a commit for each piece of work. + +## Delivery order + +1. Improve the existing dashboard and finish the local Milestone 10 checks. +2. Add live control and parameter screens using the existing command lifecycle. +3. Add historical scenario starting points. +4. Add scheduled forecasts. +5. Add the optional metrics stack. +6. Design and implement alarm acknowledgement and shelving. + +Each later piece needs its own design and two recorded checks before code. +Contributor consent and remote publication remain separate delivery actions. +Field calibration needs measured data. The owner deferred physical sleep/wake. +Continue using one SQL schema file, without numbered migrations. + +## Current problems and changes + +| Area | Current problem | Proposed change | +| --- | --- | --- | +| Navigation | Help and Settings are unavailable entries | Offer a working Guide; remove the empty Settings entry | +| Overview | No explanation of how the views fit together | Offer a short expandable starting guide and a clear well list | +| Monitoring | Diagnostics come before readings | Put readings and alarms first; expand model diagnostics when needed | +| Forecast | “Horizon” and sampling lack context | Explain duration and the interval between saved chart points | +| Scenarios | Draft controls sit beside an unrelated saved result | Label the new draft and saved result separately; explain that edits need a new run | +| Results | Baseline and confidence assume prior knowledge | Explain the comparison and score; keep limits next to the result | +| History | Identifiers compete with names and actions | Emphasise names, dates and reopening a result; keep details available | +| Alarms | Dense opening paragraph; inconsistent layout | Show the task first, explain persistence in optional help, label trigger values | +| Analytics | Database terminology and a colour-only severity ring | Explain fleet averages and show severity counts in text | +| Login | Placeholder-only fields and an unresolved photo source | Use visible labels, inline errors, password-manager support and an original text layout | + +## Shared layout and wording + +Keep the existing colour and spacing tokens, focus outline, chart patterns and +44px controls. Apply the same page heading, panel and table styles everywhere. +Use Overview, Alarms, Analytics and Guide in the main navigation. Within a well, +keep Monitoring, Forecast, Scenarios and Saved runs in that order. + +Each page starts with its purpose and next action. Optional help uses native +expandable sections. Users can open it with a keyboard, and no overlay covers +the current task. The Guide contains a first-run walkthrough and short terms. +The scenario walkthrough describes exact inputs but never submits automatically. +No global simulation notice or automatic tour is added. + +Monitoring shows recent readings, active alarms, installed controls, then trends. +Model version, tick times and recovery details sit in an expandable section. +Data age and model availability remain visible in the well header. + +Keep numerical validation in the shared request schemas. Labels explain units +and helper text explains timing. A scenario starts at minute zero of the copied +state. The baseline uses the same starting state with unchanged controls. +Changing a draft never edits an existing result or applies a live command. +Every explicit run creates a saved result. Show its original inputs separately. + +## States and responsive behaviour + +The [usability gallery](milestone-9/mockups/usability.html) covers the workspace, fleet and account screens. +Ready, empty, loading, stale, error and permission states apply to data screens. +Login adds invalid inputs; Guide contains static content and needs no data state. +Keep reading order on mobile. Stack forms before results below 760px. +Only labelled, keyboard-focusable tables may scroll horizontally. + +Retain successful data after failed reads, mark it unverified, and offer retry. +Show the last successful check time for Alarms and Analytics. A check timestamp +does not establish that the source readings are fresh; charts keep their own +sample times. Authentication and permission failures clear restricted content. +Never replay a write after a timeout. Link to history when its outcome is unknown. + +Login has visible field labels, autocomplete hints and a password visibility +button. Allow paste and password managers. Show validation and server errors in +the form, focus an error summary, and retain the username after a failed attempt. + +## Design checks + +### Check 1: task and contract review + +Source reviewed: dashboard routes, projection form/result, request schemas, +model status, control responses, alarm and analytics responses, and ADR 0032. + +The design preserves automatic read refresh, explicit writes, immutable saved +results and server permissions. It does not describe fleet averages as an +individual well. Alarm trigger values need an explicit label because they do +not represent the latest reading. A new Guide link requires a real route. +Corrections: retain read-check timestamps on failure; explain trigger values; +keep original run inputs separate from the current form draft. + +### Check 2: rendered design review + +Completed before application edits. Chromium passed 147 applicable screen, +state and width combinations at 1440, 390 and 320 CSS pixels. Checks covered +page reflow, one main heading and keyboard disclosure controls. Desktop and +phone screenshots were inspected. Corrections added compact navigation, +explicit pressure units, stacked control labels and a valid login error link. +These are design checks; the implemented application still needs its tests. + +## Verification plan + +Run types, lint and focused unit checks. Build the dashboard and exercise actual +forecast/scenario saves, history, draft retention, permissions and read failures. +Extend browser checks to Guide, Login, Alarms and Analytics, including keyboard +use, automated accessibility checks and narrow layouts. Refresh screenshots +and the demo instructions after the implementation passes. + +Use an isolated clean export and a new Compose volume for the final startup +check. Preserve the running stack and its data. Review the complete diff and +commit on the existing branch after the checks pass. + +## References + +The W3C guidance on [consistent navigation](https://www.w3.org/WAI/WCAG22/Understanding/consistent-navigation.html), +[labels and instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html), +and [accessible authentication](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html) +informs these checks. Automated checks do not establish full WCAG conformance. + +## Account and missing-page review, 2026-10-17 + +The final route inventory found a public registration form behind an API that +requires user:manage. Keep account creation available to administrators. +Other visitors get an explanation and a sign-in link. Administrators reach the +form through Create account in the header. Compact and desktop navigation +share one menu definition, including Guide. Add visible labels, +field errors, pending state and a success message. New accounts retain USER +as their default role. A failed request must not repeat automatically. +An uncertain creation response directs the administrator to check existing +accounts before another attempt. + +The missing-page view needs a main landmark, a descriptive heading, keyboard +focus and a dashboard recovery link. These changes reuse the shared layout. + +Design check 1 read the registration API, current user response, capability +matrix and both routes. The API requires user:manage, keeps the administrator +session and creates a USER unless a role is supplied. The revised UI preserves +those contracts. The gallery now includes account and missing-page views. +Design check 2 passed 18 rendered combinations at 1440, 390 and 320 CSS +pixels, before application edits. Each had one main heading and no page +overflow. The phone account and missing-page screenshots were inspected. + +## Delivery evidence, 2026-10-17 + +The clean candidate export contained 340 source and documentation files. +It used only .env.example, separate loopback ports, a separate network and +new storage. All six service images built. Schema setup exited 0, and all +six long-running services became healthy. The running local stack kept its data. + +A real browser signed in on the isolated installation and read 12 current +wells and 216 latest readings. It saved a scenario, reloaded that result, +opened Guide and confirmed unchanged installed controls and revision. + +The shared-contract and frontend suite passed 37 tests. The API passed 95 +integration tests against a disposable TimescaleDB database. The model HTTP +suite passed 14 tests after granting its temporary local servers socket access. +The first full browser pass passed four workflows before the final account +and missing-page additions. The final five-workflow pass passed in 5.9 minutes. +It covered account access, missing-page recovery, Guide, fleet views, saved +analysis, permissions, read failures, keyboard use and responsive layouts. +The account form test used mocked administrator responses; the API suite +verified server authorization against the disposable database. + +The final wording-only build passed the 37 unit tests and the complete saved +analysis browser workflow in 3.1 minutes. Its first browser attempt found an +assertion that still expected the old 404 wording. The 404 and request-limit +assertions now match the revised messages; the rerun passed. The eight screenshots +come from that final build. + +Six workspace type tasks and six lint tasks passed. The later account and +header changes passed the affected frontend checks. Prose checks reported +no errors or warnings. Review corrected retained-history pagination after a +failed read, restricted history after a 403 response, compact navigation, +login focus timing and collapsed diagnostic panels. + +The repository description and topics match the implemented features. Licence +PR 6 remains open, with no comments or reviews when checked on this date. +Contributor consent, remote publication and remote CI remain delivery items. +The owner still defers physical host sleep/wake. These checks do not establish +field calibration or external accessibility certification. diff --git a/docs/design/database-engine-upgrade.md b/docs/design/database-engine-upgrade.md new file mode 100644 index 0000000..da87ffe --- /dev/null +++ b/docs/design/database-engine-upgrade.md @@ -0,0 +1,359 @@ +# Upgrade the database engine and verify recovery + +Date: 2026-10-16 + +## Problem and target + +The schema consolidation exposed a restore failure in TimescaleDB 2.19.3. +Compressed columns containing null values can be dumped but cannot be restored. +Our historian failed at the model_version column of a compressed chunk. +The successful physical recovery check remains the starting recovery method. + +The target is PostgreSQL 16 with TimescaleDB 2.30.2. Both the original image +and target image are identified by a digest, which fixes their exact contents. + +| Purpose | Image digest, under timescale/timescaledb | +| --- | --- | +| Original PostgreSQL 14 / TimescaleDB 2.19.3 | sha256:0f5553d37abbe0f20bfe0a04dab5d1238955b5cda2711aba403090f95b6e51ff | +| Target PostgreSQL 16 / TimescaleDB 2.30.2 | sha256:6f139d56042989bd35f50ba5986e492cd32e35cc6778c50be2659add298dc09f | + +The vendor support matrix includes PostgreSQL 16 for both extension versions. +PostgreSQL 14 cannot run the extension version containing the fix. PostgreSQL 16 +therefore provides an intermediate engine that can run the old extension before +updating it. The TimescaleDB release selected here was published on 2026-09-29. + +## Options considered + +**Upgrade PostgreSQL using copies of its database files, then update TimescaleDB +(chosen).** PostgreSQL's pg_upgrade tool carries the data to a new major version. +It avoids decoding the broken compressed values during a logical restore. +The vendor maintainer recommends this sequence for the reported defect. +It requires both sets of PostgreSQL binaries and space for separate volumes. + +**Decompress the old database before a logical backup.** This could avoid the +faulty compressed format, but would rewrite the historian and require more disk +space. It also needs a complete recompression and policy check afterward. + +**Keep PostgreSQL 14 and physical backups.** This is the verified fallback. +It leaves the logical restore failure unresolved and prevents current +TimescaleDB updates. It is not the long-term target. + +## Design and sequence + +1. Save the original generated schema contract outside the source tree. +2. Take a new physical backup using pg_basebackup. Verify its manifest using + pg_verifybackup. Preserve this backup unchanged. +3. Recover a separate copy with the original image. Disable background jobs + and application connections while comparing records. +4. Record application rows, sequence positions, role properties and grants, + legacy schema history, and the schema installation identity. +5. Stop the recovered server cleanly. Initialize a separate PostgreSQL 16 + data volume with the same database administrator, encoding, and locale. +6. Run pg_upgrade --check, then pg_upgrade in its default copy mode. Do not use + --link. Keep TimescaleDB at 2.19.3 during this step. +7. Start the upgraded copy with the target image. In a new connection to each + database containing the extension, run ALTER EXTENSION timescaledb UPDATE + TO '2.30.2'. Check template databases as well as the application database. +8. Compare all preserved records and permissions. Generate the target schema + contract from schema.sql in a separate empty database and review differences. +9. Accept the new engine identity with the guarded installer command below. +10. Take a logical backup of the upgraded copy. Restore it into another empty + database on the same target engine. Use timescaledb_pre_restore() before + pg_restore and timescaledb_post_restore() afterward. Restore the archive's + database-level grants separately, as described below. Require zero restore + errors and compare the restored data and database structure. +11. Run schema, API, and model persistence tests against the target engine. +12. Only after the rehearsal passes, stop application writers, take a final + copy of the cleanly stopped original cluster, and repeat the verified steps. + Preserve the original volume and restart the services against the new one. +13. Verify through the dashboard's public /api path after the API starts. + Older dashboard images need the reload described below. Verify readiness, current + telemetry, persisted controls, and database permissions. Record actual results. + +data/upgrade/Dockerfile builds the temporary pg_upgrade tool image. It keeps the +original binaries and their dependencies separate from the target binaries. +It runs as the postgres operating-system user. Its build arguments default to the +two verified image references. The old-binary.sh wrapper confines the older +library search path to the old server. This image is an upgrade tool, not a +replacement database service. + +### Running the binary conversion + +The old data directory below must be a recovered copy whose server has stopped +cleanly. The new directory must be empty. Both volumes must belong to the +postgres operating-system user. The example uses the project's admin database +role and en_US.utf8 locale; check those values on the source first. + +```bash +docker build -t well-pg-upgrade:20261016 data/upgrade + +old_copy_volume=well_engine_old_copy_20261016 +new_volume=well_engine_target_20261016 +work_volume=well_engine_upgrade_work_20261016 + +docker run --rm --entrypoint initdb \ + --mount "type=volume,source=$new_volume,target=/new" \ + well-pg-upgrade:20261016 -D /new -U admin \ + --encoding=UTF8 --locale=en_US.utf8 \ + --auth-local=trust --auth-host=scram-sha-256 + +upgrade_options=( + -b /opt/old-bin -B /usr/local/bin -d /old -D /new -U admin + -o '-c shared_preload_libraries=timescaledb -c timescaledb.max_background_workers=0 -c shared_buffers=128MB' + -O '-c shared_preload_libraries=timescaledb -c timescaledb.max_background_workers=0 -c shared_buffers=128MB' +) +upgrade_mounts=( + --mount "type=volume,source=$old_copy_volume,target=/old" + --mount "type=volume,source=$new_volume,target=/new" + --mount "type=volume,source=$work_volume,target=/upgrade" +) +docker run --rm "${upgrade_mounts[@]}" well-pg-upgrade:20261016 \ + "${upgrade_options[@]}" --check +docker run --rm "${upgrade_mounts[@]}" well-pg-upgrade:20261016 \ + "${upgrade_options[@]}" +``` + +Run the second command only after the check succeeds. The reduced shared buffer +size applies to these temporary processes. The original configuration and +authentication files are copied to the target after conversion and checked at +startup. The pg_upgrade-generated delete_old_cluster.sh is not a recovery step. +Keep the old copy until verification and retention decisions are complete. + +### Restoring the browser-to-API path + +The original dashboard image resolved the API service name only when nginx +loaded. Changing the API address could leave the proxy returning 502 while +its static-page health check stayed healthy. + +The [ADR 0017 amendment](../adr/0017-observability-baseline.md) adds automatic +DNS refresh and checks both the page and /api/ready. Rebuild and recreate the +dashboard to use that fix. For an older dashboard image, the recovery step is: + +```sh +docker compose exec -T dashboard nginx -t +docker compose exec -T dashboard nginx -s reload +``` + +Require the validation command to succeed before reloading. Check /api/ready +through the dashboard port, then sign in and load current readings in a browser. +A direct API health check or HTTP 200 from the dashboard's static page alone +does not establish that this path works. + +### Restoring a logical backup completely + +A database archive and cluster roles are separate backup artifacts. Protect both +because they contain account records or credential hashes. Use umask 077 when +creating them. The rehearsal uses pg_dump -Fc --create for the database and +pg_dumpall --roles-only for the roles. Restore into a separate server, create +the original roles, and create an empty database with the original name and +owner from template0. Install the exact TimescaleDB version first. + +Run timescaledb_pre_restore(), then serial pg_restore --exit-on-error against +that database, followed by timescaledb_post_restore(). Check all exit codes. +Do not use --jobs for a full TimescaleDB restore; see the +[restore finding](schema-extension.md#restore-comparison-follow-up-2026-10-17). + +The pre-created database is required by this restore sequence. pg_restore +without --create does not apply the archive's database-level grants. In our +rehearsal, this initially gave both service roles temporary-table permission +through PUBLIC, even though their table grants were correct. Generate the +archive's creation SQL with: + +```sh +pg_restore --schema-only --create --file=archive-schema.sql database.dump +``` + +Review and apply its database ownership and database GRANT/REVOKE statements +to the restored database. For this project there are four database permission +statements: revoke PUBLIC's old CONNECT/TEMPORARY grants, grant PUBLIC CONNECT, +and grant CONNECT to twin_reader and well_api. The owner is admin. Read the +actual archive when role names differ. Verify both service credentials and +confirm TEMP and schema CREATE remain denied before starting applications. + +The normal installer also reapplies the configured service restrictions. A +restoration check must verify the restrictions before declaring recovery complete. + +## Accepting the generated reference + +For the original consolidated baseline, use the `e150992` installer and SQL +with the previous `cd09691` contract. Their schema source is identical. Today's +schema also contains schedule and alarm tables, so it cannot perform that +engine-only acceptance directly. Follow the +[existing-database sequence](../operations/database-updates.md) to keep the +installer, source and engine reference together, then add later tables separately. + +The installer rejects an engine or contract mismatch during normal startup. +Use the Bash helper and historical directories from the update guide after +verifying the converted copy's records, permissions and catalog: + +```sh +schema_python "$schema_engine_dir/data/src/schema.py" accept-engine \ + --previous-contract "$schema_legacy_dir/data/sql/schema-contract.json" +schema_python "$schema_engine_dir/data/src/schema.py" check +``` + +POSTGRES_* must identify the database being checked. The generated contract in +the selected historical tree must describe its schema on the target engine. +The saved previous contract must match the database's recorded identity. + +accept-engine runs under the existing installer lock. It requires the same +schema.sql checksum and legacy file checksums in both references. It verifies +the actual target engine and every catalog section, then updates only the +recorded contract checksum. It writes schema.engine.accepted to auditevent in +the same transaction, recording the database actor, both engine versions, both +contract checksums, and the source checksum. An audit failure rolls back the +identity change. Repeating a successful acceptance performs checks without +adding another event. It does not apply SQL, seed records, or service grants. + +The command verifies database identity and structure. Backup restoration, +record preservation, and application tests remain separate release gates. +Do not use acceptance to approve an arbitrary schema edit. schema.sql remains +the only maintained SQL schema. Do not reintroduce numbered schema files. + +## Failure handling and recovery + +If the copy or pg_upgrade check fails, keep the original server and investigate +the isolated result. A failed extension update or catalog comparison blocks +acceptance and service startup. A failed restore blocks the live change. +No step deletes the original database volume or the verified physical backup. + +Once the new database accepts writes, the old volume no longer contains the +latest readings or changes. Returning to it then requires an explicit decision +about those newer records. Do not silently switch back after writes resume. + +## Verification record + +The isolated rehearsal completed on 2026-10-16: + +- The new physical backup passed pg_verifybackup and recovered on a separate + volume. PostgreSQL's compatibility check and copy-mode upgrade both passed. +- All 10,165,815 original application rows, 474,202 aggregate rows, 15 sequence + positions, role records, and effective permissions survived the engine change. +- The accepted database added one engine audit event. Its logical archive + restored 10,165,816 application rows and all aggregate rows with matching + content fingerprints, sequence positions, roles, and effective permissions. +- The original engine reproduced the compressed-null error. The upgraded engine + decoded the same value, and a separate compressed 100-row null fixture passed + a complete logical backup and restore. +- The final schema gate passed 83 tests. A later comparison self-review added a + duplicate-entry regression, and all 15 normalization tests passed afterward. +- The full Python suite passed 249 tests; 51 database opt-ins were skipped in + that run. All 51 ran separately: 38 schema cases and 13 model persistence cases. +- All 89 API integration tests passed using restricted database credentials. +- Ruff, formatting, strict mypy, prose checks, and the installer build passed. + +The rehearsal found two engine representation differences. TimescaleDB removes +nine internal triggers and the finalized information column. PostgreSQL can +render equivalent varchar-to-text array casts in two ways after pg_upgrade. +The checker handles those verified differences and retains literal, type, and +duplicate-entry checks. The shipped TimescaleDB upgrade SQL confirms the removed +objects. Permission comparison expands implicit grants and compares effective +rights, because raw grant ordering changed while the 759 relation grants did not. + +Initial checks failed on the removed catalog fields and the equivalent array +casts. Acceptance refused the latter until comparison was corrected. The first +tool image lacked a pg_resetwal wrapper; the corrected image passed pg_upgrade. +Test-launcher retries corrected a disposable database name and the Vitest path. +The temporary fingerprint helper also needed its role and grant queries corrected +before the complete comparisons succeeded. Those failed attempts are not counted +as passing checks. + +The verified physical backup is well_engine_backup_20261016. The older +well_schema_physical_backup_20261013 remains available. + +### Final local conversion + +The running stack was converted on 2026-10-16 after the rehearsal passed. +Application writers stopped before the original database shut down. A new cold +copy preserved 10,228,395 application rows and 475,294 aggregate rows. All their +content fingerprints, 15 sequence positions, role records, and effective +relation grants matched after conversion. The target schema passed comparison +with the fresh reference. Engine acceptance added one audit event. + +PostgreSQL statistics were rebuilt using vacuumdb --analyze-in-stages. The +extension update also reported its new internal job-history limit of 1,000 +successes and failures. Application retention and compression policies matched +the generated reference. + +The active volume is well-simulation_timescaledb_pg16_20261016, selected in the +local .env through TIMESCALE_DATA_VOLUME. The original volume, +well-simulation_timescaledb_data, remains PostgreSQL 14 and was verified cleanly +shut down. Both physical backups remain. Returning to the original now would +omit records written after the switch, so it is a recovery source rather than +an automatic rollback destination. + +Final checks confirmed: + +- Normal installer startup exited successfully on the converted database. +- All six long-running services were healthy. Twin-core reported 12 running + wells at model version 0.3.0; ingestion reported 216 mappings. +- The API and dashboard returned HTTP 200. The latest historian reading was + about six seconds old during verification. +- Fresh API and twin database connections retained their restricted privileges. + Both still lacked schema creation and temporary-table permission. +- Accounts, active controls, and the legacy schema ledger matched their + pre-shutdown content fingerprints. Exactly one engine acceptance event existed. +- All four continuous-aggregate refresh jobs reported Success with zero failures. +- Background workers and autovacuum were enabled again on the normal server. + +Temporary servers, disposable copies, and test credentials were removed after +verification. Protected logical archives remain under /tmp, alongside the +verification records. The database archive alone is insufficient for complete +recovery; retain its roles archive and database-grant instructions as well. + +### Delivery review and operational checks, 2026-10-16 + +Review covered the guarded engine acceptance, transaction rollback on audit +failure, SQL comparison boundaries, pinned images, and preserved-volume selection. +No blocking engine or schema finding remained. The live schema identity and catalog +check passed again without changing grants or application data. + +The delivery rerun passed 237 Python tests in the sandbox. Twelve HTTP cases +could not open loopback sockets there; all 12 passed with socket access. +The 51 database cases were opt-in skips in that first run. A separate schema +suite passed 69 cases, including all 38 schema database cases. The 13 model +persistence cases and 89 API cases passed during the upgrade verification above; +they were not repeated for this documentation and delivery review. +Lint, formatting, strict types, OpenAPI validation, and prose checks passed. + +Two live telemetry checks also passed: + +- Stopping only the gateway made ingestion return 503 and increment its + reconnect counter. No historian writes occurred after the gateway stopped. + Restarting the gateway restored fresh writes without restarting ingestion. + The model remained ready throughout. The first witness compared against a + time before graceful shutdown and wrongly rejected legitimate final rows. + A corrected repeat compared against the fully stopped gateway and passed. +- A separate read-only client exercised the deployed poll function over real + Modbus connections. One deliberately delayed response spanned a gateway + update. The function retried once, returning all 216 readings with one + timestamp and version within its normal deadline. It inserted no rows. + The probe's retry counter is separate from the running ingestion counter. + +The owner deferred physical host sleep/wake on 2026-10-16. The passive observer +was stopped without a suspend event. Clock unit tests do not establish a real +suspend result. Both local roadmaps retain this distinction. + +Browser fault injection reached the shipped root error page. The initial reload +checks failed because the proxy retained the API's old address and returned 502. +Disabling the test browser's cache did not fix this. Proxy error logs and the +current container addresses established the cause. Reloading nginx restored the +route. Automatic recovery after address changes remained open at that delivery. +The later ADR 0017 amendment records the dynamic proxy and readiness fix. +After the reload, /api/health returned HTTP 200 and the browser error-page +reload returned to login successfully. The injected component error and the +unauthenticated 401 responses were expected parts of that browser check. +An ordinary demo sign-in then rendered the overview, analytics, Alarm Center, +and wellhead detail pages with no browser errors. + +## References + +- [TimescaleDB issue 8893](https://github.com/timescale/timescaledb/issues/8893): + the compressed-null defect and the maintainer's binary-upgrade recommendation. +- [PostgreSQL upgrade guide](https://www.tigerdata.com/docs/deploy/self-hosted/upgrades/upgrade-pg): + the supported engine combinations and pg_upgrade sequence. +- [TimescaleDB Docker upgrade guide](https://www.tigerdata.com/docs/deploy/self-hosted/upgrades/upgrade-docker): + explicit extension updates in each database. +- [TimescaleDB 2.30.2 release](https://github.com/timescale/timescaledb/releases/tag/2.30.2): + the target extension release. +- [Schema consolidation](consolidated-schema.md): the original recovery finding. diff --git a/docs/design/historical-scenarios.md b/docs/design/historical-scenarios.md new file mode 100644 index 0000000..32d949f --- /dev/null +++ b/docs/design/historical-scenarios.md @@ -0,0 +1,201 @@ +# Historical scenario branches + +Date: 2026-10-17 +Status: Implemented and verified locally +Related decision: [ADR 0027](../adr/0027-historical-branching.md) + +## Purpose and user flow + +A user can compare a proposed control schedule with unchanged controls from an +exact saved well state. The Scenarios page starts with Current state selected. +Choosing Saved snapshot opens a time finder. The user chooses a time in their +browser's displayed time zone, searches, and explicitly selects one candidate. +The form shows the candidate's exact time and compatibility before Run scenario. +Forecasts continue to use current state. A scenario changes only private model +copies. It cannot change live controls or the saved source snapshot. + +The result and Saved runs identify Current state or Saved snapshot. Result +metadata includes the snapshot identifier and base time. The existing name, +duration, sample interval, schedule, baseline, charts and table keep their roles. + +## Data and compatibility + +The existing `twin_state_snapshot` table stores complete process state every +30 seconds, with model version, parameter fingerprint, tick count and skipped +ticks. Its primary key contains both `snapshot_id` and `simulated_at`. +Snapshot identifiers travel as decimal strings, bounded by signed PostgreSQL +bigint, because JavaScript numbers cannot represent every bigint exactly. +Requests include both key fields and the well identifier. + +Snapshots do not store parameter values. Branching therefore requires the +installed model version and the current resolved parameter fingerprint to match +the snapshot. Decode every state field, verify its UTC timestamp against the row, +validate controls and physical bounds through the model boundary, and reject +future, missing, malformed or incompatible snapshots. Never replace a failed +selection with current state. A live model tick need not be current for a valid +historical branch, but resolved parameters must exist for the active well. + +The snapshot finder returns at most 20 candidates, newest first, at or before the +requested time. Search covers the preceding 24 hours and no more than 89 days +before request time. Users can change the search time to inspect older periods. +Use 21 rows to report that older candidates exist; no unbounded offset scan. +Each candidate carries its exact composite key, version and compatibility reason. +Show the first three cards initially, with an explicit Show more snapshots button +for the rest, so the finder does not bury the scenario inputs. +The server repeats compatibility and state validation when running the scenario. +A selection can become invalid after a parameter change or retention job. + +## Service boundaries + +The public API requires `scenario:run` for finding snapshots and executing a +historical scenario. Existing read limits, analysis write limits and body limits apply. +The model service owns snapshot reads because it already holds the read grant +and understands state compatibility. Its internal snapshot route requires the +service token before checking well existence. Query fields are strict and bounded. +Snapshot reads use the existing read semaphore and three-second database timeout. +Projection work shares the existing two-slot semaphore and bounded 5–120 minute +window, with at most 16 control changes. Browser requests never carry the token. + +A scenario request adds optional `baseSnapshot: { snapshotId, simulatedAt }`. +Omission retains current-state behavior. A finder GET accepts `at` in UTC and +returns `wellheadId`, `checkedAt`, `items` and `hasMore`. Each item contains +`snapshotId`, `simulatedAt`, `modelVersion`, `compatible` and a bounded reason. +The selection must refer to the route's well. Missing source returns 404; +incompatible or invalid source returns 409. Dependency failures return 503. +Public error text explains the next step without exposing internal exceptions. + +## Model logic and time + +Create an immutable `ProjectionBase` from the selected process state, its saved +controls and the matching resolved parameters. Both scenario and baseline start +from that same copy. Do not run live recovery or settle the snapshot toward now. + +The model advances by `t_n = t_base + n * MODEL_STEP_SECONDS`. +`t_base` is `base.state.simulated_at` in UTC; `n` counts model steps; +`MODEL_STEP_SECONDS` is one second. Saved chart samples occur at +`t_base + k * step_seconds`, where `k` counts samples and `step_seconds` is the +requested sample interval in seconds. Schedule minute offsets are relative to +`t_base`. `generated_at` remains the actual execution time. + +Current alarm rules are copied at execution and saved with the run. The UI says +that historical scenarios use those rules, rather than claiming to reconstruct +past alarm configuration. The existing heuristic confidence formula remains +unchanged. Add `historical_snapshot` as an explanatory reason without an invented +age penalty or calibrated probability. Missing recovery metadata stays unknown; +we do not invent a past recovery time. + +## Persistence and retention + +Save `branchOrigin` inside the existing immutable `scenario_run.base_state` JSON: +`{ kind: 'snapshot', snapshotId, simulatedAt }`, or `{ kind: 'current' }`. +The existing `base_simulated_at` remains queryable. Runs retain complete base state, +parameter values, initial controls, schedule and alarm rules. Source snapshot +expiry therefore does not remove saved run inputs. Legacy rows without origin +mean current state, the only source supported when those rows were written. +Expose origin in fresh responses, saved responses and summary history. Reject +malformed stored origin instead of relabelling it current. + +No SQL change or numbered migration is needed. This amends ADR 0027's proposed +separate provenance columns to use the existing immutable base-state record. + +Both snapshot and scenario-point retention use simulated timestamps and a 90-day +policy. A newly created historical run does not receive 90 days from creation. +The finder limits sources to 89 days old to avoid immediately expired points; +older branches can still have much less chart retention than current runs. +Show this before execution and with historical results. Expired points retain +the existing 410 response and history entry. Do not promise an exact deletion +time because retention operates on TimescaleDB chunks. + +## Screen states and accessibility + +Mockups cover current, ready, selected, loading, empty, incompatible, stale, +error, permission and saved-result states at desktop and phone widths. Reuse +shared spacing, typography and colors. Use labelled native date/time and radio +controls, visible keyboard focus, text compatibility reasons, and a status +message for search completion. Candidate cards wrap at 320px. The time field +states the browser time zone; the server receives UTC. No color-only state. + +Search and selection keep the user's scenario name and control schedule. Editing +the search time clears the old selection, so the displayed search and submitted +base cannot disagree. A search failure leaves the schedule but clears selectable +candidates. No selection disables historical execution. Revoked permission clears +restricted candidates and disables the form. Historical metadata stays attached +to the saved result even when the user edits a new draft. + +## Alternatives + +Raw-reading reconstruction needs a state estimator and cannot recover the full +internal process state directly. Replaying an old parameter fingerprint with +new parameters would change the meaning of the historical branch. Storing full +parameter sets in future snapshots could support more versions later, but it +would require a separate compatibility design and schema change. + +## Design checks + +Check 1 read ADR 0027, the canonical SQL snapshot and scenario tables, recovery +validation, projection runtime/confidence, persistence, shared request/response +schemas, API saved reads, and the scenario form. It found the parameter-value gap, +composite identity and retention based on simulated time. The design above +addresses each before implementation. The confidence code has no general state +age penalty; the design keeps its formula and labels historical origin separately. + +Check 2 passed all 30 screen/state combinations at 1440, 390 and 320 CSS +pixels: reflow, headings, keyboard selection and blocked execution. Phone +inspection found cramped card padding; the mockup spacing was corrected and +the same checks reran before implementation. + +## Verification plan + +Model tests cover exact snapshot lookup, bigint identity, wrong well/time, +compatibility, malformed state, source age, private state isolation, point times, +saved controls, metadata persistence, capacity release and legacy current runs. +API tests cover authentication, strict finder queries, safe upstream errors, +provenance through saved reads/history and the unchanged forecast contract. +Browser tests cover selection, time conversion, retained schedule, permission +loss, no silent fallback, saved reload, mobile reflow and automated accessibility. +An isolated real stack must run and reopen a historical scenario while preserving +live controls and the source snapshot. Record failures and unrun gates before +review and commit. + +## Verification on 2026-10-17 + +The affected Python run passed 45 tests across historical state, projections and +the HTTP boundary. A separate real TimescaleDB test passed with the model's +restricted database role. It selected an exact retained source, rejected a wrong +well and a timestamp one microsecond away, saved complete run metadata and +points, and confirmed the source stayed unchanged. The other database test in +that module was deselected in this targeted run. + +The projection API suite passed 13 tests. Shared schema and frontend unit tests +passed 15 tests. All six workspace type tasks and all six lint tasks passed, +with three cached tasks in each run. A final website type check also passed. +Python types passed across 33 source files; affected lint and formatting passed. +Both OpenAPI specifications and prose/diff checks passed. + +The real isolated browser workflow passed in 41.6 seconds. It ran and reopened +a historical scenario, checked exact origin metadata and unchanged live controls, +then covered empty and incompatible candidates, late search responses, stale +selection rejection, retained drafts, dependency errors, permission loss, 320px +reflow and the selected Axe WCAG A/AA rules. This Chromium check does not establish +full accessibility conformance. + +Initial checks exposed a test fixture using base defaults instead of per-well +parameters, a wrong injected-clock keyword and a missing origin in an older +frontend fixture. These were corrected. The first HTTP test command could not +open sockets in the sandbox; the run with loopback access passed. Lint rejected +an intentionally oversized number literal in a rejection test; explicit Number +conversion preserves that test case without a lossy source literal. + +Review added exact microsecond comparisons, strict UTC precision, a compact +three-candidate opening list, stale-selection clearing and a light native-control +scheme matching the workspace. Saved runs retain historical provenance after +source expiry. No SQL change, live-control mutation or numbered migration was +required. Remote CI and additional assistive-technology checks have not run. + +Final image verification confirmed the light native-control scheme, three initial +candidate cards, explicit expansion to six, and desktop/phone layouts without +overflow. The screenshots in `docs/screenshots` came from that running image. +The tested images then replaced the main local API, model and dashboard services. +All durable control records matched before and after restart. A final browser +confirmed 12 current wells, selected a compatible source, saved and reopened its +exact branch, and verified unchanged live controls. diff --git a/docs/design/historical-scenarios/index.html b/docs/design/historical-scenarios/index.html new file mode 100644 index 0000000..2a6fe55 --- /dev/null +++ b/docs/design/historical-scenarios/index.html @@ -0,0 +1 @@ +Historical scenario design
Historical scenario designIllustrative data
diff --git a/docs/design/historical-scenarios/screens.js b/docs/design/historical-scenarios/screens.js new file mode 100644 index 0000000..32e5a59 --- /dev/null +++ b/docs/design/historical-scenarios/screens.js @@ -0,0 +1,10 @@ +const state = document.querySelector('#state'); +function render() { + const s = state.value; + const blocked = ['loading', 'empty', 'incompatible', 'stale', 'error', 'permission', 'ready'].includes(s); + const messages = { loading: 'Finding saved snapshots…', empty: 'No snapshots in this search window. Try another time.', incompatible: 'These snapshots use different parameters. Choose another time.', stale: 'The model parameters changed. Find and select a compatible snapshot again.', error: 'We could not load snapshots. Your scenario inputs remain below.', permission: 'Your account cannot run model analysis.' }; + document.querySelector('#workspace').innerHTML = `

WH-001 · Scenarios

Compare a proposed change

Try a control schedule on a copy of this well.

How historical scenarios work

Select an exact saved state. The model uses matching parameters and alarm rules active when you run the scenario.

Chart points expire according to their simulated dates. Older scenarios may remain available for less time.

${s === 'result' ? 'Saved result' : 'New scenario inputs'}

${s === 'result' ? '

Starting state: Saved snapshot, Oct 16, 2026, 10:00:00 AM (Africa/Lagos).

Snapshot identifier: 9007199254740993

Generated Oct 17, 2026. Chart times begin at the snapshot time.

This result keeps its saved state, controls, parameters and alarm rules.

' : `
Starting state
${s !== 'current' && s !== 'permission' ? `

Times in Africa/Lagos. Search covers the preceding 24 hours, up to 89 days ago.

${messages[s] ? `

${messages[s]}

` : ''}${['ready','selected','incompatible'].includes(s) ? `

${s === 'incompatible' ? 'Different parameters, unavailable for this model.' : 'Compatible model and parameters.'}

Model 1.0.0 · Snapshot 9007199254740993

` : ''}

Show up to three candidates first. Use Show more snapshots to inspect the rest.

Historical runs use alarm rules active when you run them. Chart retention follows simulated dates.

` : ''}${s === 'permission' ? `

${messages[s]}

` : ''}

Change choke opening to 80% at minute 5. Keep the same baseline controls.

`}
`; + const candidate = document.querySelector('#candidate'); + candidate?.addEventListener('change', () => { document.querySelector('#run').disabled = !candidate.checked; }); +} +state.addEventListener('change', render);render(); diff --git a/docs/design/milestone-9/accessibility-verification.md b/docs/design/milestone-9/accessibility-verification.md new file mode 100644 index 0000000..274d5d1 --- /dev/null +++ b/docs/design/milestone-9/accessibility-verification.md @@ -0,0 +1,146 @@ +# Milestone 9 design and accessibility verification + +Date: 2026-10-16 (UTC) + +## Scope + +The review covers Fleet, Monitoring, Forecast, Scenarios, Saved runs, and their +shared navigation. The target is WCAG 2.2 AA. The evidence below applies to the +recorded workflows and browser combination; it is not an external certification. + +The [visual specification](visual-specification.md) records the component rules +and state matrix. The [mockup gallery](mockups/index.html) contains illustrative +fixtures. The application screenshots in `docs/screenshots` use real API data. + +## Checks and results + +| Check | Result | +| --- | --- | +| Mockup coverage | 96 screen/state/viewport combinations passed at 1440, 390 and 320 pixels, without page errors or page-level horizontal overflow | +| Complete browser suite | All three workflows passed in 4.4 minutes, including real analysis, saved reloads, unchanged installed controls and failure recovery | +| Automated accessibility | Axe rules tagged WCAG 2 A/AA, 2.1 AA and 2.2 AA reported no violations in the tested views and error states | +| Keyboard | Named collapsed navigation, skip link, route focus, saved-result focus, schedule add/remove focus, linked error summary and chart tables passed | +| Reflow and text sizing | All five views passed at 320 CSS pixels and with the root text size doubled at a 1280-pixel viewport | +| Reduced motion | Browser checks used the reduced-motion preference; shared-shell transitions and chart animation are disabled | +| Validation | Out-of-range target produced an invalid-field state, a focused summary and a link to the input; no model request was sent | +| Refresh recovery | Required-read failure retained the draft and prior readings with an unverified warning; retry recovered without discarding the draft | +| Saved history | Failed manual refresh retained the prior list; recovery and an empty search passed | +| Permission | Denied analysis retained the editor; denied required reads produced an accessible recovery page | +| Screen reader | Orca 46.1 with headed Chromium and eSpeak NG generated the expected announcements for the scenario workflow | +| Shared contracts and presentation | 37 tests passed, including schema errors mapped to stable schedule-row identifiers | +| Static checks | Six workspace type-check tasks and six lint tasks passed; the final affected website checks also passed | + +The browser harness uses Playwright 1.63.0 and its bundled Chromium. It paces +requests below the existing API limit and uses the seeded demo account. Individual +response failures are intercepted in the browser. The test does not stop shared +services, raise request limits, or modify installed well controls. + +## Contrast + +Ratios were calculated from the actual shared CSS token values using relative +sRGB luminance. The text pairs exceed 4.5:1. The focus and input-boundary pairs +exceed the 3:1 non-text threshold. + +| Pair | Contrast ratio | +| --- | --- | +| Main text / white panel | 15.17:1 | +| Secondary text / page background | 5.54:1 | +| White action text / blue button | 9.15:1 | +| Success text / success background | 6.89:1 | +| Warning text / warning background | 8.53:1 | +| Error text / error background | 6.93:1 | +| Baseline line / white | 5.74:1 | +| Scenario line / white | 6.14:1 | +| Focus indicator / white | 6.99:1 | +| Input boundary / white | 3.75:1 | + +Chart series also use different line patterns, visible legends, units, and a +labelled data table. Colour is not the only way to identify a series or status. + +## Orca workflow and limits + +Orca ran in an isolated Xvfb desktop and D-Bus session. Its packages were extracted +to `/tmp`, without changing installed system packages. Speech Dispatcher initialized +successfully with eSpeak NG and an ALSA null output device. The check inspected +Orca's generated speech log and browser focus assertions; no person listened to +the synthesized audio. + +The workflow opened a well, navigated to Scenarios, entered an invalid target, +submitted with the keyboard, followed the error link back to the input, corrected +the schedule, ran it, and opened the plotted-values table. Selected output: + +```text +Check your inputs clickable. +Step 1 target: Enter a target from 0 to 100%. link. +Target (%) 101 spin button required. +invalid entry. +Enter a target from 0 to 100%. +Choke adjustment heading level 2 clickable. +View plotted values as a table toggle button expanded. +Projection values region clickable. +``` + +Orca also announced the telemetry category and loading-view status. The visible +reading age changes each second, while the live region changes only when the +freshness category changes. This avoids announcing each second of the age counter. + +The first setup attempt lacked the speech backend and its dependencies. The final +run waited for Speech Dispatcher readiness and confirmed successful initialization. +Optional mouse-review and non-speech audio-cue support were unavailable because +Wnck and GStreamer were absent. Keyboard navigation, speech generation and form +feedback were exercised. Mouse exploration, audible quality, braille, NVDA, +VoiceOver, Firefox and Safari were not tested. + +The 320px check exercises the layout width described in the W3C reflow guidance. +The text-sizing check changes the root font size; it is not a claim that every +browser's native zoom implementation was tested. + +## Repeating the automated checks + +From `monorepo`, with the local Docker application running: + +```sh +pnpm check-types +pnpm lint +pnpm exec vitest run apps/website/app packages/dto +pnpm --filter @well-simulation/website test:browser +``` + +`e2e/design.spec.ts` validates the gallery without API requests. +`e2e/accessibility.spec.ts` checks semantics, focus, accessibility rules, reflow, +validation and retained-state recovery. `e2e/workspace.spec.ts` checks real model +runs, saved reloads, unchanged controls, delayed freshness, mobile views, and +service/session/result failures. Set `UPDATE_SCREENSHOTS=1` to refresh the eight +application screenshots during that workflow. + +For a desktop with Orca installed, start it with `orca --debug-file=orca.log` and +repeat the keyboard workflow above. The [GNOME Orca documentation](https://help.gnome.org/orca/introduction.html) +describes its options. W3C publishes the +[WCAG 2.2 requirements](https://www.w3.org/TR/WCAG22/) and +[reflow guidance](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html). +[Playwright's accessibility guide](https://playwright.dev/docs/accessibility-testing) +explains the limits of automated scans. + +Remote CI, a fresh-volume start of this complete revision, and the owner's +explicitly deferred physical host sleep/wake check were not run in this UI pass. +The earlier API and Python verification remains recorded in the parent Milestone 9 +design. Those suites were not repeated for these frontend changes. + +## Usability follow-up, 2026-10-17 + +The expanded suite passed all five browser workflows in 5.9 minutes, including +Login, Guide, Alarms, Analytics, administrator account-form states and the +missing-page view. Checks covered error focus, visible labels, password-manager +hints, keyboard disclosure controls, compact navigation, retained reads and +clearing restricted data after a 403 response. Axe found no violations for the +selected rules in these tested states. The administrator browser test used +mocked responses; real API authorization passed in the integration suite. + +The usability gallery passed 147 applicable screen/state/width combinations. +Account and missing-page additions passed 18 more combinations before code. +The existing 96-combination gallery still passed. All checks used Chromium. +The earlier Orca results remain historical evidence; this pass did not rerun +Orca or add other assistive-technology combinations. + +The complete candidate also passed its isolated fresh-volume startup and real +scenario smoke check. Remote CI and physical host sleep/wake did not run. diff --git a/docs/design/milestone-9/mockups/index.html b/docs/design/milestone-9/mockups/index.html new file mode 100644 index 0000000..ad2210f --- /dev/null +++ b/docs/design/milestone-9/mockups/index.html @@ -0,0 +1,56 @@ + + + + + + Milestone 9 design review + + + + + + +
+ Milestone 9 · visual designIllustrative data for design review +
+ +
+
+
+ +
+ +
+
+
+ + diff --git a/docs/design/milestone-9/mockups/mockups.css b/docs/design/milestone-9/mockups/mockups.css new file mode 100644 index 0000000..147e69b --- /dev/null +++ b/docs/design/milestone-9/mockups/mockups.css @@ -0,0 +1,399 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--twin-bg); + color: var(--twin-text); + font: + 16px/1.5 system-ui, + sans-serif; +} +a { + color: var(--twin-primary); + text-underline-offset: 3px; +} +button, +input, +select { + font: inherit; + border: 1px solid var(--twin-control-border); + border-radius: 6px; + min-height: 44px; + padding: 8px 12px; + background: white; + color: var(--twin-text); + max-width: 100%; +} +button { + cursor: pointer; + font-weight: 600; +} +button:disabled { + cursor: not-allowed; +} +.primary { + background: var(--twin-primary); + color: white; + border-color: var(--twin-primary); +} +:focus-visible { + outline: 3px solid var(--twin-focus); + outline-offset: 3px; +} +h1 { + font-size: 30px; + line-height: 1.25; + margin: 8px 0; +} +h2 { + font-size: 18px; + margin: 0; +} +h3 { + font-size: 16px; + margin: 0; +} +p { + margin: 8px 0; +} +label, +summary { + font-size: 14px; + font-weight: 600; +} +summary { + cursor: pointer; + min-height: 44px; + padding: 10px 0; +} +.review { + padding: 16px 24px; + background: #e9edf5; + border-bottom: 1px solid var(--twin-control-border); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.review div { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.review span { + font-size: 14px; + color: var(--twin-muted); +} +.shell { + display: flex; + min-height: 100vh; +} +aside { + width: 300px; + flex-shrink: 0; + padding: 24px; + background: white; + border-right: 1px solid var(--twin-line); +} +.brand { + font-size: 20px; + font-weight: 650; + text-decoration: none; +} +nav { + display: flex; + gap: 20px; +} +aside nav { + flex-direction: column; + margin: 40px 0; +} +aside p, +.muted { + color: var(--twin-muted); + font-size: 14px; +} +.content { + min-width: 0; + flex: 1; +} +.account { + padding: 12px 24px; + display: flex; + justify-content: flex-end; + gap: 16px; + background: white; + border-bottom: 1px solid var(--twin-line); +} +main { + padding: 24px; + max-width: 1440px; + margin: auto; +} +.heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 24px; +} +.eyebrow { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--twin-muted); +} +.badge { + display: inline-block; + border: 1px solid var(--twin-line); + padding: 4px 8px; + border-radius: 5px; + font-size: 13px; + background: var(--twin-bg); +} +.ok { + color: var(--twin-success); + background: var(--twin-success-bg); +} +.warning { + color: var(--twin-warning); + background: var(--twin-warning-bg); +} +.tabs { + flex-wrap: wrap; + border-bottom: 1px solid var(--twin-line); + margin-bottom: 24px; + gap: 16px; +} +.tabs a { + padding: 12px 0; + text-decoration: none; + color: var(--twin-muted); +} +.tabs [aria-current] { + color: var(--twin-primary); + border-bottom: 3px solid var(--twin-primary); + font-weight: 600; +} +.panel { + background: white; + border: 1px solid var(--twin-line); + border-radius: var(--twin-radius); + min-width: 0; + overflow: hidden; + margin-bottom: 20px; +} +.panel-head, +.body { + padding: 20px; +} +.panel-head { + border-bottom: 1px solid var(--twin-line); +} +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} +.analysis { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 20px; +} +.metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} +.metric { + padding: 16px; + background: white; + border: 1px solid var(--twin-line); + border-radius: 9px; +} +.number { + font-size: 26px; + font-weight: 650; + font-variant-numeric: tabular-nums; +} +.filter { + display: flex; + gap: 16px; + align-items: end; + flex-wrap: wrap; + margin: 20px 0; +} +.field { + margin-bottom: 16px; +} +.field label { + display: block; + margin-bottom: 6px; +} +.field input, +.field select { + width: 100%; +} +.step { + padding: 16px; + background: var(--twin-bg); + border: 1px solid var(--twin-line); + border-radius: 6px; + margin: 16px 0; +} +.notice { + padding: 16px; + background: var(--twin-warning-bg); + color: var(--twin-warning); + border: 1px solid #dbc38b; + border-radius: 7px; + margin-bottom: 20px; +} +.error { + background: var(--twin-danger-bg); + color: var(--twin-danger); + border-color: #d792a0; +} +.empty { + text-align: center; + padding: 40px 24px; + color: var(--twin-muted); +} +.scroll { + overflow: auto; + max-width: 100%; +} +table { + border-collapse: collapse; + width: 100%; + font-size: 14px; +} +th, +td { + padding: 14px 16px; + border-bottom: 1px solid var(--twin-line); + text-align: left; + vertical-align: top; +} +th { + background: #f8fafc; + color: var(--twin-muted); +} +.wide { + min-width: 720px; +} +caption { + text-align: left; + padding: 16px; + color: var(--twin-muted); +} +.chart { + width: 100%; + height: 260px; +} +.legend { + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 14px; +} +.legend span:first-child { + color: var(--twin-baseline); +} +.legend span:last-child { + color: var(--twin-scenario); +} +.summary { + padding: 16px; + border-top: 1px solid var(--twin-line); + background: var(--twin-bg); +} +.skeleton { + height: 18px; + background: #e1e6ef; + border-radius: 4px; + margin: 16px 0; + width: 80%; +} +.skeleton:nth-child(2n) { + width: 55%; +} +.skip { + position: fixed; + left: 16px; + top: -100px; + padding: 12px; + background: white; + z-index: 10; +} +.skip:focus { + top: 10px; +} +[aria-invalid="true"] { + border: 2px solid var(--twin-danger); +} +.field-error { + font-size: 14px; + color: var(--twin-danger); +} +@media (max-width: 1100px) { + aside { + display: none; + } + .analysis { + grid-template-columns: 260px minmax(0, 1fr); + } +} +@media (max-width: 760px) { + main { + padding: 16px; + } + .analysis, + .grid { + grid-template-columns: minmax(0, 1fr); + } + .review { + padding: 12px 16px; + } + .metrics { + grid-template-columns: minmax(0, 1fr); + } + .panel-head, + .body { + padding: 16px; + } + .chart { + height: 240px; + } + .account { + padding: 12px 16px; + } +} +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} +figure { + margin: 0; +} +figcaption { + font-size: 14px; + color: var(--twin-muted); +} +@media (max-width: 760px) { + .chart { + height: auto; + aspect-ratio: 640/300; + } + .chart text { + font-size: 24px; + } + .chart text:nth-child(5) { + display: none; + } +} diff --git a/docs/design/milestone-9/mockups/mockups.js b/docs/design/milestone-9/mockups/mockups.js new file mode 100644 index 0000000..b8a035a --- /dev/null +++ b/docs/design/milestone-9/mockups/mockups.js @@ -0,0 +1,157 @@ +/* Design fixtures only. This gallery is never bundled into the application. */ +const screenControl = document.querySelector("#screen"); +const stateControl = document.querySelector("#state"); +const names = { + fleet: "Wellhead overview", + monitoring: "Monitoring", + forecast: "Forecast", + scenario: "Scenarios", + runs: "Saved runs", +}; +const params = new URLSearchParams(location.search); +screenControl.value = Object.hasOwn(names, params.get("screen")) + ? params.get("screen") + : "fleet"; +stateControl.value = [ + "ready", + "loading", + "empty", + "stale", + "error", + "permission", + "invalid", +].includes(params.get("state")) + ? params.get("state") + : "ready"; +const panel = (title, body) => + `

${title}

${body}
`; +const field = (id, label, value, type = "text", invalid = false) => + `
${invalid ? `

${id === "horizon" ? "Enter a horizon from 5 to 120 minutes." : "Enter a target between 0 and 100%."}

` : ""}
`; +const table = (caption, heads, rows) => + `
${heads.map((h) => ``).join("")}${rows.map((row) => `${row.map((c) => ``).join("")}`).join("")}
${caption}
${h}
${c}
`; +function chart(mode = false) { + const scenario = mode === true; + const history = mode === "history"; + return `
Tubing pressure (PSI), 10:00–10:30 UTC
2,5002,0001,50010:0010:1510:30${scenario ? 'Change at 5 min' : ""}
${scenario ? "┄ Unchanged baseline" : history ? "━ Tubing pressure" : "━ Forecast"}${scenario ? "━ Scenario" : ""}
View plotted values as a table${table( + "Illustrative samples, UTC", + [ + "Time", + scenario + ? "Baseline (PSI)" + : history + ? "Pressure (PSI)" + : "Forecast (PSI)", + ...(scenario ? ["Scenario (PSI)"] : []), + ], + [ + ["10:00", "2,150", ...(scenario ? ["2,150"] : [])], + ["10:30", "1,940", ...(scenario ? ["1,480"] : [])], + ], + )}
`; +} +function notice(screen, state) { + if (state === "stale") + return `
${screen === "runs" ? "History has not been refreshed" : screen === "forecast" || screen === "scenario" ? "Current model check overdue" : "Readings are stale"}

${screen === "runs" ? "Saved results retain their original timestamps. Refresh the list to find newer runs." : screen === "forecast" || screen === "scenario" ? "The saved result remains unchanged. A new run requires a current model state." : "Last reading 46 seconds ago. Values remain visible with their original timestamps."}

`; + if (state === "error") + return ``; + if (state === "permission") + return ``; + return ""; +} +function render() { + const screen = screenControl.value, + state = stateControl.value, + analysis = ["forecast", "scenario"].includes(screen); + const url = new URL(location); + url.searchParams.set("screen", screen); + url.searchParams.set("state", state); + history.replaceState(null, "", url); + let body = ""; + if (state === "permission" && !analysis) body = ""; + else if (state === "loading" && !analysis) + body = panel( + "Loading " + names[screen].toLowerCase(), + '

Reading the latest data…

', + ); + else if (screen === "fleet") + body = `

Configured wellheads

${state === "empty" ? "0" : "4"}

Tracked signals

${state === "empty" ? "0" : "18"}

Active alarms

${state === "empty" ? "0" : "2"}
${field("search", "Search wellheads", "")}
${panel( + "Wellhead snapshot", + state === "empty" + ? '

No wellheads configured

Add wellheads through the existing configuration process.

' + : table( + "Latest readings and model status", + [ + "Wellhead", + "Pressure (PSI)", + "Oil rate (bbl/d)", + "Data age", + "Model", + "Abnormal", + ], + [ + [ + 'WH-001', + "2,150", + "820", + state === "stale" + ? "46 seconds · stale" + : "4 seconds · current", + "Running", + "0", + ], + [ + 'WH-002', + "1,920", + "645", + "8 seconds · current", + "Running", + "2", + ], + [ + "WH-003", + "Unavailable", + "Unavailable", + "No readings", + "Unavailable", + "Unavailable", + ], + ], + ), + )}`; + else if (screen === "monitoring") + body = `
${panel("Model status", 'Running

Successful process time: 10:00:00 UTC

Model version: synthetic reference well

')}${panel("Installed controls", '

Choke: 70%

Pump: Running · Revision 12

Read-only installed state.

')}
${panel("Current parameters", state === "empty" ? '

No readings received

Model status and installed controls are shown independently.

' : '

Tubing pressure

2,150 PSI

Oil flow rate

820 bbl/d

')}${panel("Pressure history", state === "empty" ? '

No samples in this time range.

' : chart("history"))}`; + else if (analysis) { + const scenario = screen === "scenario"; + body = `
${panel(scenario ? "New scenario inputs" : "Forecast inputs", `

Start from a copy of the current model state.

${state === "invalid" ? `` : ""}
${scenario ? field("name", "Scenario name", "Choke adjustment") : ""}${field("horizon", "Horizon (minutes)", state === "invalid" && !scenario ? "1" : "30", "number", state === "invalid" && !scenario)}${field("sample", "Sample every (seconds)", "60", "number")}

5–120 minutes. Samples every 5–300 seconds.

${scenario ? `

Step 1

${field("offset", "At minute", "5", "number")}
${field("target", "Target (%)", state === "invalid" ? "101" : "80", "number", state === "invalid")}

Use ordered times. Up to 16 changes.

` : ""}

${state === "loading" ? "Calculating and saving the result. Your draft is retained." : "Results are saved with their original inputs."}

`)}
${panel(state === "empty" ? "No result selected" : scenario ? "Choke adjustment" : "Saved forecast", state === "empty" ? '

Run an analysis or reopen a saved result.

Browse saved runs
' : `Saved result · 10:00 UTC

30 minutes · Samples every 60 seconds

${chart(scenario)}
Confidence: moderate

Heuristic model assessment. No sampled critical exceedance.

Original run inputs

Horizon 30 minutes. Sample interval 60 seconds.${scenario ? " Choke 80% at minute 5." : ""}

`)}${state === "loading" ? '

Previous saved result remains visible while the new run is calculated.

' : ""}
`; + } else + body = `
${field("run-search", "Search this page", "")}
${panel( + "Saved scenarios", + state === "empty" + ? '

No saved scenarios

Run a scenario to add its result here.

Create a scenario
' + : table( + "Saved results, newest first", + ["Name", "Created (UTC)", "Horizon", "Sample interval", "Result"], + [ + [ + "Choke adjustment", + "16 Oct 2026, 10:00", + "30 min", + "60 sec", + 'Open result', + ], + [ + "Baseline check", + "16 Oct 2026, 09:40", + "15 min", + "60 sec", + 'Open result', + ], + ], + ), + )}

Showing 1–2 of 2

`; + document.querySelector("main").innerHTML = + `

${screen === "fleet" ? "Production workspace" : "Wellhead workspace"}

${screen === "fleet" ? names[screen] : "WH-001"}

${screen === "fleet" ? "Telemetry, alarms and model health in one view." : "North field · Production well"}

${state === "stale" ? "Check overdue" : "Model running"}
${screen === "fleet" ? "" : ``}${state === "invalid" && !analysis ? '

Invalid-input state applies to the scenario editor. This screen keeps its normal layout; read failures use the Error state.

' : ""}${notice(screen, state)}${body}`; +} +screenControl.addEventListener("change", render); +stateControl.addEventListener("change", render); +render(); diff --git a/docs/design/milestone-9/mockups/usability.html b/docs/design/milestone-9/mockups/usability.html new file mode 100644 index 0000000..4e5bb95 --- /dev/null +++ b/docs/design/milestone-9/mockups/usability.html @@ -0,0 +1,58 @@ + + + + + + Dashboard usability design + + + + + +
+ Usability design reviewIllustrative data +
+ + + + diff --git a/docs/design/milestone-9/mockups/usability.js b/docs/design/milestone-9/mockups/usability.js new file mode 100644 index 0000000..ad64ead --- /dev/null +++ b/docs/design/milestone-9/mockups/usability.js @@ -0,0 +1,228 @@ +/* Static design fixtures, never imported by the application. */ +const screen = document.querySelector("#screen"); +const state = document.querySelector("#state"); +const params = new URLSearchParams(location.search); +screen.value = params.get("screen") || "fleet"; +state.value = params.get("state") || "ready"; +const panel = (title, body) => + `

${title}

${body}
`; +const help = (title, body) => + `
${title}
${body}
`; +const field = (id, label, value, type = "text") => + `
`; +const table = (heads, rows) => + `
${heads.map((h) => ``).join("")}${rows.map((r) => `${r.map((c) => ``).join("")}`).join("")}
${h}
${c}
`; +const purpose = { + account: [ + "Create an account", + "Administrators create accounts for dashboard readers.", + ], + missing: ["Page not found", "Check the address or return to the dashboard."], + fleet: [ + "Wellhead overview", + "Choose a well to check its readings, forecast its behaviour, or try a change.", + ], + monitoring: [ + "WH-001", + "Monitoring · Check recent readings and alarms before starting an analysis.", + ], + forecast: [ + "WH-001", + "Forecast · See how this well may behave if its current controls stay the same.", + ], + scenario: [ + "WH-001", + "Scenarios · Compare a proposed change with the same well running unchanged.", + ], + runs: [ + "WH-001", + "Saved runs · Open a previous result and inspect the inputs that produced it.", + ], + alarms: [ + "Alarm Center", + "Check the most severe alarms first, then open the affected well for current readings.", + ], + analytics: [ + "Analytics", + "Compare recent trends across the fleet. Open a well for its individual history.", + ], + guide: [ + "Guide", + "Learn the workflow, then return here when a term or result needs an explanation.", + ], + login: ["Sign in", "Use the account your administrator gave you."], +}; +function render() { + const s = screen.value, + st = state.value, + analysis = ["forecast", "scenario"].includes(s); + history.replaceState(null, "", `?screen=${s}&state=${st}`); + let body = ""; + if (s === "fleet") + body = + help( + "New here? Start with one well", + '
  1. Open a well and check its readings and data age.
  2. Run a forecast to keep its controls unchanged.
  3. Try a scenario to compare a change, then reopen it from Saved runs.
Read the guide', + ) + + panel( + "Your wells", + st === "empty" + ? "

No wells configured.

" + : table( + ["Well", "Pressure (PSI)", "Flow (bbl/d)", "Readings", "Alarms"], + [ + [ + 'WH-001', + "2,150", + "820", + "4 seconds ago", + "0", + ], + ["WH-002", "1,920", "645", "4 seconds ago", "2"], + ], + ), + ); + if (s === "monitoring") + body = + panel( + "Current parameters", + '

Pressures

Tubing pressure: 2,150 PSI

Casing pressure: 950 PSI

Production

Flow: 820 bbl/d

Water cut: 12%

', + ) + + panel("Active alarms", "

No active alarms for this well.

") + + help( + "How to read this page", + "

Check the data age before comparing values. A missing reading means unavailable; a zero is a measured value.

", + ) + + help( + "Model diagnostics", + "

Model running. Last calculation: 10:00 UTC. Tick and recovery details appear here.

", + ); + if (analysis) { + const isScenario = s === "scenario"; + body = + help( + isScenario ? "Try your first scenario" : "How forecasts work", + isScenario + ? "
  1. Keep the 30-minute duration.
  2. Set the choke to 80% at minute 5.
  3. Run the scenario, then compare the dashed baseline and solid scenario line.

The example may raise or lower a value, depending on the starting state. The running well keeps its controls.

" + : "

The model copies the current well state and holds the controls constant. Each run saves a separate result.

", + ) + + `
${panel(isScenario ? "New scenario inputs" : "Forecast inputs", `${isScenario ? field("name", "Scenario name", "Choke adjustment") : ""}${field("horizon", "Duration (minutes)", "30", "number")}

How far ahead to calculate, from 5 to 120 minutes.

${field("sample", "Save a point every (seconds)", "60", "number")}

The interval between chart points. It must divide the duration.

${isScenario ? `
Change 1${field("offset", "At minute", "5", "number")}

Count from the start of this run.

${field("value", "Target (%)", "80", "number")}
` : ""}

A run saves a new result. Editing this draft does not change the result beside it.

`)}${panel(st === "empty" ? "Your result will appear here" : "Saved result", st === "empty" ? "

Choose inputs and run the model, or open a saved result.

" : '

Choke adjustment · Created 17 October, 10:00

These are the inputs from this saved run. Your current draft may differ.

Tubing pressure (PSI), elapsed minutes

0 min30 min

Dashed: unchanged controls. Solid: proposed change.

Confidence: low. The model has no field calibration.

')}
`; + } + if (s === "runs") + body = + panel( + "Saved runs", + st === "empty" + ? "

No saved runs yet. Start with a forecast or scenario.

" + : table( + ["Result", "Created", "Duration", "Action"], + [ + [ + "Choke adjustment", + "17 Oct, 10:00", + "30 minutes", + 'Open scenario', + ], + ], + ), + ) + + help( + "About saved results", + "

Each result keeps its original inputs. Search filters this page of results. Run a new calculation when you want a different starting state.

", + ); + if (s === "alarms") + body = + help( + "How alarms open and clear", + "

An alarm opens after three consecutive readings cross a threshold. Three normal readings clear it. The value below is the value that opened the alarm.

", + ) + + panel( + "Active alarms", + st === "empty" + ? "

No active alarms.

" + : table( + [ + "Severity", + "Well", + "Parameter", + "Value when triggered", + "Threshold", + ], + [ + [ + "Critical", + 'WH-002', + "Tubing pressure", + "3,200 PSI", + "> 3,000 PSI", + ], + ], + ), + ); + if (s === "analytics") + body = + help( + "What these charts show", + "

Each point averages available readings across wells over two minutes. A fleet average can hide a change at one well. Check each chart’s timestamps.

", + ) + + panel( + "Alarms triggered in the last 24 hours", + "
Critical
2
Warning
5
", + ) + + panel( + "Pressure trends", + st === "empty" + ? "

No readings in this period.

" + : '

Two-minute averages across wells. Pressure in PSI.

', + ); + if (s === "guide") + body = + panel( + "Start with one well", + '
  1. Choose a well from Overview and check its data age.
  2. Open Forecast and run with the default inputs.
  3. Open Scenarios and try a choke change at minute 5.
  4. Compare tubing pressure and flow against the unchanged baseline.
  5. Open Saved runs to revisit the calculation.
', + ) + + help( + "Terms used in the dashboard", + "
Baseline
The copied well with its original controls.
Water cut
The fraction of liquid production that is water.
Confidence
A model quality indicator, not a probability.
", + ); + if (s === "login") + body = panel( + "Your account", + `
${field("username", "Email or username", "", "text")}${field("password", "Password", "", "password")}

Ask your administrator if you need an account.

`, + ); + if (s === "account") + body = + st === "permission" + ? panel( + "Ask your administrator", + '

Your account cannot create users. Ask an administrator for an account.

Sign in', + ) + : panel( + "New dashboard reader", + `
${field("username", "Username", "")}${field("email", "Email", "", "email")}${field("password", "Password", "", "password")}${field("confirm", "Confirm password", "", "password")}

This account starts with the USER role.

`, + ); + if (s === "missing") + body = 'Back to the dashboard'; + let notice = ""; + if (st === "error") + notice = analysis + ? '' + : s === "login" + ? '' + : ''; + if (st === "stale") + notice = + '
Last checked 2 minutes ago.

Check the timestamps before relying on these values.

'; + if (st === "permission") + notice = + ''; + if (st === "loading" && !analysis) + notice = '

Loading the requested view…

'; + if (st === "invalid") + notice = ``; + document.querySelector("#workspace").innerHTML = + `

Well Simulation

${purpose[s][0]}

${purpose[s][1]}

${["monitoring", "forecast", "scenario", "runs"].includes(s) ? '' : ""}${notice}${st === "permission" && !analysis && s !== "account" ? "" : body}`; +} +screen.addEventListener("change", render); +state.addEventListener("change", render); +render(); diff --git a/docs/design/milestone-9/visual-specification.md b/docs/design/milestone-9/visual-specification.md new file mode 100644 index 0000000..355ade3 --- /dev/null +++ b/docs/design/milestone-9/visual-specification.md @@ -0,0 +1,142 @@ +# Milestone 9 visual specification + +Date: 2026-10-16 + +## Purpose and design artifacts + +The [interactive mockup gallery](mockups/index.html) covers Fleet, Monitoring, +Forecast, Scenarios, and Saved runs. Select a screen and state in its review +header. That header and its illustrative fixtures belong to the design artifact +only. The application continues to use API responses. + +Open the gallery through a local static server at the repository root, or open +its HTML file directly. Query parameters such as `?screen=monitoring&state=stale` +make each design reproducible. Desktop and mobile share the same document and +responsive rules. The gallery is the proposed layout; application screenshots +in `docs/screenshots` record the implemented result. + +## Shared visual rules + +Use the values in the [shared token stylesheet](../../../monorepo/apps/website/app/styles/tokens.css). Components reference these +variables so later changes do not require independent colour edits. + +| Element | Specification | +| --- | --- | +| Surfaces | Page #f4f6f9; panels #ffffff; separators #dce1e8; control boundaries #788598 | +| Text | Primary #1d2637; secondary #586477; system sans-serif; body 16px and 1.5 line height | +| Hierarchy | Page heading 30px/1.25, weight 650; panel heading 18px/1.4, weight 650; subheading 16px; labels 14px; captions at least 12px | +| Actions | Blue #023bb5 with white text; visible focus outline #344dc4, 3px wide with 3px offset | +| Status | Green #17613c on #eff8f2; amber #693f00 on #fff7e7; errors #a31b32 on #fff1f3; always include words | +| Chart series | Baseline #59677d, dashed; scenario #553afe, solid; historian palette uses dark blue, green, purple and brown with distinct dash patterns | +| Spacing | 4, 8, 12, 16, 20, 24 and 32px; 20px panel padding and grid gap; 16px on phones | +| Shape | Panels 9px radius; controls 6px; status badges 5px; thin borders and no decorative shadows | +| Form fields and workspace action buttons | At least 44px high; visible label; consistent focus and disabled treatment; inline text links remain underlined | +| Numbers | Tabular numerals; units in headings or beside values; missing values read “Unavailable”; zero stays zero | + +## Layout and responsive behaviour + +The desktop shell has a collapsible navigation column and a content area that +can shrink. Hide the desktop column below 1280px and show the header navigation. +Analysis uses a 300px input column and a flexible results column, reducing the +input column to 260px below 1000px. Below 760px, forms and results stack in reading +order. Small metric groups also stack when their text would become cramped. + +At 320 CSS pixels, navigation wraps, labels and summaries wrap, and controls use +the available width. Only inherently two-dimensional tables scroll horizontally, +inside labelled keyboard-focusable regions. Charts remain within their panel and +have a data table alternative. Long names and identifiers wrap. Use a static +header on narrow screens so it cannot cover most of a zoomed viewport. + +A skip link moves focus to the main content. Navigation to another page moves +focus to the main heading region and updates the document title; background +polls do neither. Sticky desktop headers have enough scroll offset to keep +focused elements visible. Reduced-motion preferences disable shell transitions +and chart animation. + +## State matrix + +Every screen has a ready state in the gallery. The following states are part of +the design and implementation contract, including first load and later refresh. + +| Screen | Loading | Empty | Stale | Error | Permission | +| --- | --- | --- | --- | --- | --- | +| Fleet | Named loading status; retain old rows during a poll | Distinguish no configured wells from no filter matches | Show age and stale words per row; overdue model check independently | Initial read gets a recovery page; optional model failure keeps historian rows | Denied initial read gives an access explanation and sign-in/overview recovery | +| Monitoring | Load shell; retain readings while refreshing | Missing readings and missing history have separate explanations; installed controls can still load | Server age plus browser elapsed time; original timestamp remains visible | Optional model/control failure stays within its panel; required read failure has a retry page | Required read denied stops display; no fabricated zero readings | +| Forecast | Announce calculation; preserve draft and previous immutable result | No selected result offers run and saved-history paths | Mark current model status overdue; never relabel an immutable result as live | Keep draft; distinguish validation, expired session, forbidden, expired result, busy service and unknown submission outcome | Disabled form with explanation if capability is absent; server denial keeps draft | +| Scenarios | Same as Forecast; no automatic repeat submission | Same as Forecast | Same as Forecast | Field errors link to controls; timeout links to scenario history | Same as Forecast | +| Saved runs | Announce history read and retain existing rows | Distinguish no runs, no search matches and an empty page | Show when the list was checked; saved results keep their creation timestamps | Recovery and manual retry; expired selected result explains retention | Access explanation; no claim that model-copy isolation means owner-only visibility | + +The gallery includes invalid-input variants for Forecast and Scenarios. Both +use the same error-summary and field-message design. +Fleet, Monitoring and Saved runs do not expose model-input validation. +Initial required-read failures show a recovery view with an explicit GET retry. +Later failures retain readings and drafts, mark them unverified, and allow retry. Optional +model failures must not replace historian data. Retaining a draft across a +successful status poll is required; a lost login session requires authentication. + +## Charts and forms + +Use the returned sample timestamp, display its timezone, and state the unit for +each plotted signal. Do not connect missing samples or treat them as zero. +Historian gaps keep the existing gap rule. Projection samples use their own +returned timestamps. Boolean signals use step lines and text state labels. +Baseline and scenario differ by line pattern as well as colour. Labels and a +plain-language description identify what is plotted. The expandable data table +contains the same plotted samples with bounded pagination and column headings. + +Schedule markers identify input times, not exact threshold crossings. Confidence +is a heuristic label with reasons, not a probability or uncertainty band. Show +the earliest returned threshold exceedance and preserve its sampled meaning. + +The shared request schemas own all numerical bounds and schedule validation. +Submit validation focuses an error summary with links to each invalid control. +Each invalid field has `aria-invalid` and its own explanatory text. Adding a +schedule step focuses its time field; removing a step focuses a remaining step +or the add button. Limit the editor to the schema's maximum step count. + +A pending submit is guarded against duplicates while focus remains usable. +Announce calculation start and completion once. A timeout can have an unknown +outcome, so link to the matching history and require an explicit new submission. +A rejected validation/permission/rate-limit request does not imply a saved run. + +## Accessibility verification + +Target WCAG 2.2 AA. Check automated rules, accessible names, keyboard-only use, +focus after navigation and validation, chart tables, contrast, reduced motion, +320px reflow, 200% text sizing, and status announcements. Avoid announcing every +one-second age update. A single polite region announces freshness category or +refresh-state changes. Validate actual screen-reader behaviour separately from +automated accessibility-tree inspection and state the tested combination. + +The W3C [reflow guidance](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) +allows two-dimensional tables to scroll within their own region. Its +[status-message guidance](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) +requires updates to be available to assistive technology without moving focus. +[Focus must remain visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html). +[Playwright's accessibility guidance](https://playwright.dev/docs/accessibility-testing) +explains why automated scans need manual checks. These checks establish evidence +for the tested workflows, not external certification or universal device coverage. + +## Design review record + +The gallery passed 96 combinations: six states on each of five screens, plus +invalid inputs on Forecast and Scenarios, each at 1440, 390 and 320 pixels. +There were no uncaught browser errors or page-level horizontal overflow. Desktop +and phone screenshots were visually inspected. Phone chart labels were enlarged +following that inspection. The repeatable check is +`monorepo/apps/website/e2e/design.spec.ts`. + +The illustrative gallery omits the full signal catalogue and uses representative +rows. The implemented dashboard includes all configured wells and the available +signal groups, using API data. Original saved inputs and confidence explanations +can extend below the chart; they remain in normal reading order. + +## Usability amendment, 2026-10-17 + +The [usability design](../dashboard-usability.md) records a later review of the +complete dashboard. Its gallery adds Alarms, Analytics, Guide and Login. +Use the working Guide in place of the unavailable Help entry. The revised +sidebar is 240px wide. Analysis uses a 320px input column, 280px below 1000px, +and stacks below 760px. Form fields follow one column so their explanations +fit. Model diagnostics expand on request. Drafts and saved results keep +separate labels and inputs. This amendment supersedes the older layout sizes. diff --git a/docs/design/observability.md b/docs/design/observability.md new file mode 100644 index 0000000..a81d355 --- /dev/null +++ b/docs/design/observability.md @@ -0,0 +1,239 @@ +# Optional service metrics + +Date: 2026-10-17 +Status: Implemented and verified locally +Decision: [ADR 0029](../adr/0029-metrics-stack.md) + +## Purpose and scope + +Help a maintainer locate a stopped service, delayed historian write, model lag or +failed background job. Keep the plant dashboard focused on wells and analysis. +The optional `observability` Compose profile adds Prometheus and Grafana. The +normal stack starts without them or their credentials. + +Prometheus collects the four existing service endpoints every 15 seconds, with a +5-second scrape timeout. Grafana reads Prometheus and presents the versioned +Service health dashboard. Neither component writes controls, parameters, alarm +state or historian readings. No database exporter or external notification service +is part of this addition. + +```mermaid +flowchart LR + T[Model /metrics :8000] --> P[Prometheus] + A[API /metrics :3000] --> P + M[Modbus /metrics :9102] --> P + I[Ingestion /metrics :9101] --> P + P --> V[(7-day metrics volume)] + P --> G[Grafana Service health] + G --> U[Authenticated local browser :3010] +``` + +The four Compose targets are `twin-core:8000`, `api:3000`, `modbus:9102` and +`ingestion:9101`. Prometheus is available only on host loopback port 9090; +Grafana uses loopback port 3010. Prometheus scrapes the internal service addresses. +The dashboard proxy also exposes the API metrics at `/api/metrics` without login. +That endpoint reports service activity, not well readings. The other three +metrics endpoints have no host route in the default Compose setup. +Each optional service has its own named volume. Prometheus retains at most seven +days or 512MB of retained blocks, whichever limit applies first. Its WAL and +in-memory working data need additional capacity; the block cap is not a hard +container disk quota. Grafana keeps local accounts and preferences in its own +volume. Stopping the profile preserves both volumes. + +## Images and credentials + +Verified official images on 2026-10-17: + +- Prometheus LTS 3.13.4, digest `sha256:87861b8cf91579109319ebc300f3f1060e6da9c05d6ae8ad15a20c879e84e32e`. +- Grafana OSS 13.2.4, digest `sha256:dbbc38ea21103cefb50396340cd88afec779c7e070b0a7bf5a75b4ce46651d3f`. + +Pin both digests. The images run as their supplied non-root users. Drop Linux +capabilities and prevent privilege escalation. Mount configuration read-only and +keep writable state in named volumes. Health checks use the tools verified inside +the images; the Grafana image exposes `/run.sh`, `wget` and `grafana`. + +Grafana uses a separate local administrator account, with its password in the +ignored `.env` file as `GRAFANA_ADMIN_PASSWORD`. Its startup wrapper refuses an +absent or short password before invoking `/run.sh`. An empty default during Compose +interpolation lets the ordinary stack work without optional credentials; starting +Grafana without a suitable value fails explicitly. Disable anonymous access, +self-registration, telemetry reporting, update checks and automatic installation +of suggested plugins. Use the built-in panels and light theme. Allow 180 seconds +for first-start storage initialization before counting failed health checks. + +The password initializes a new Grafana database. Changing the environment value +later does not reset an existing account; document the explicit reset procedure. +Do not print the password in logs or commit it. The local setup may generate a +random value when no value exists, preserving any existing configured credential. +The Grafana account has no relationship to the well dashboard's roles. + +## Readiness and time semantics + +A successful scrape means the metrics endpoint answered. Readiness means a service +reported that it could do its work. The dashboard presents these as separate rows. +A failed or absent scrape never produces a green readiness value. + +Reuse each service's existing readiness authority: + +- Telemetry: expose `well_sim_telemetry_ready` from the current `ServiceHealth` + object during `/metrics`. Do not keep another readiness state machine. +- Model: calculate its existing readiness when serving `/metrics`, so the model + gauge reflects tick and fleet state at collection time. This calculation uses + in-memory state and adds no database query. +- API: record `well_sim_api_database_ready` and + `well_sim_api_readiness_checked_timestamp_seconds` when the existing `/ready` + database probe finishes. Change the API container health check to `/ready`, + matching its dependency-sensitive role. The metrics route performs no new + database probe. Suppress readiness values whose probe is older than 30 seconds or lies in the future. +- Ingestion: set `well_sim_ingestion_last_commit_timestamp_seconds` only after + the database commits a complete reading batch. Its initial zero means that + this process has not confirmed a commit. + +For a successful ingestion scrape, compute `age = t - c`, where `t` is the +Prometheus wall clock in seconds and `c` is the last commit's UTC epoch seconds. +Display age only when `c > 0` and `age >= 0`. A backwards clock that puts the +recorded timestamp in the future produces an unavailable value. Age continues to +increase while ingestion reports a dependency failure. A missing scrape produces +Unknown rather than an old value presented as current. + +Rates use `rate(counter[5m])`; Prometheus accounts for counter resets. Charts show +gaps when series are absent or too few samples exist. Do not fill absent failure +series with a healthy zero. A missing series may mean that no event of that type +has occurred yet; the reachability row supplies the service context. + +Keep published metric names. The API and telemetry services already use `api_` +and `wellhead_` names, while the model uses `well_sim_`. Renaming these would break +existing consumers. New readiness/timestamp metrics use `well_sim_`. Recording +rules provide the shared query definitions for readiness and commit age. + +## Dashboard and interaction + +The dashboard opens at the last 30 minutes and refreshes every 15 seconds. +Use Grafana's native time picker, refresh control, keyboard navigation and panel +inspection. Disable edits to the provisioned dashboard; change its versioned JSON +when the shared definition needs an update. Use a stable dashboard UID. + +Panel order: + +1. Brief reading guide, including reachability/readiness and missing-sample meaning. +2. Four reachability cards, then four readiness cards in the same service order. +3. Pipeline activity: committed readings/second and seconds since last commit. +4. Model state: running well count and simulation lag in seconds. +5. API activity: requests/second and 95th-percentile request duration in seconds. +6. Expandable automation row: scheduled-job outcomes, scheduler scan age and + control-command outcomes. A job may calculate for up to its 60-second lease; + treat a scan age above 90 seconds as overdue. + +Use one unit per chart axis, explicit titles, series names and panel descriptions. +Use blue for ordinary numeric series; reserve green, amber and red for named +service states. Show Unknown in neutral text for missing/stale readiness. Use +Down for a failed scrape and Not ready for an observed readiness value of zero. +Users can open the data table from Grafana's panel inspection menu. + +Use Grafana's light theme and native responsive grid. Desktop cards use four +columns. Phone mockups stack cards and charts, keep controls at least 44px tall, +and wrap descriptions. The design set includes normal, warming, stopped service, +dependency failure, stale probe, no samples, query error and permission states. +The final review must inspect the actual Grafana rendering as well as mockups. + +## Failure behavior + +| Condition | Expected presentation/action | +| --- | --- | +| Metrics endpoint stops responding | Scrape Down; readiness Unknown; chart gap | +| Service answers but cannot use its dependency | Scrape Reachable; readiness Not ready | +| API readiness probe stops completing | Readiness Unknown after 30 seconds | +| Ingestion has never committed | Last-commit age Unknown | +| Ingestion loses the database after a commit | Not ready; age increases while metrics remain reachable | +| Counters reset on restart | Rate handles reset; no invented negative throughput | +| Fewer than two samples | No samples until a rate can be calculated | +| Prometheus query fails | Grafana's query error, with no cached green status | +| Grafana password missing | Refuse startup; core services still start normally | +| Profile stops and starts | Metrics and Grafana state survive in their volumes | + +## Alternatives + +Manual Grafana setup would leave each installation with different queries. +Provisioning keeps one reviewable dashboard definition. A new database exporter +would add credentials and a fifth monitored service without helping the immediate +pipeline questions. Adding the profile to default startup would make the local +well demo depend on optional tooling. Reusing current readiness checks avoids +another health-state implementation or another polling loop inside the API. + +## Design checks + +Check 1 traced actual metric names, ports, service health objects, commit order, +model readiness calculation, API health checks and official image startup tools. +It found that the heartbeat-age gauge can stop changing after a transport failure, +and that the API health check currently asks only for liveness. The design adds +a commit timestamp and a bounded-age readiness observation, and switches the API +container probe to its existing readiness route. The default profile must still +parse and start with no optional password. + +Check 2 passed 24 rendered states at 1440, 390 and 320 pixels, with no horizontal +overflow. It checked focus, unknown values and service/dependency distinctions. +Eight readiness cases covered absent scrapes, failed probes, zero timestamps, +stale probes, the 30-second boundary and future timestamps. The future-time case +added the explicit nonnegative-age condition. The credential review confirmed +that an empty optional value must parse during normal Compose startup, while +the Grafana wrapper rejects it before boot. Actual image checks follow below. + +## Verification plan + +Use `promtool check config`, rule validation and rule tests for missing scrapes, +readiness failures, stale probes, initial timestamps and clock reversal. Test the +service metric changes against real HTTP handlers and the API's restricted role. +Start the optional profile in the isolated stack, verify all targets and the +provisioned dashboard, and inspect desktop and phone rendering. Test a stopped +metrics target without stopping the main installation, then restore it. Confirm +that restarting the optional services retains their data and that an absent +password fails before Grafana can start. Review, deploy locally and commit. + +## References + +- [Prometheus installation](https://prometheus.io/docs/prometheus/latest/installation/) +- [Prometheus storage](https://prometheus.io/docs/prometheus/latest/storage/) +- [Prometheus releases](https://prometheus.io/download/) +- [Grafana Docker configuration](https://grafana.com/docs/grafana/latest/setup-grafana/configure-docker/) +- [Grafana provisioning](https://grafana.com/docs/grafana/latest/administration/provisioning/) +- [Grafana releases](https://grafana.com/grafana/download) + +## Verification on 2026-10-17 + +Both design checks preceded implementation. The pinned `promtool` accepted the +configuration and all three recording rules; 15 rule cases passed. Twenty +Python handler/ingestion tests and the API readiness integration test passed. +The API test used the restricted database account. API types and lint passed, +as did strict Python typing across 65 files, changed-file lint/format checks, +prose checks and the whitespace review. + +The isolated installation built all four changed service images. Four targets +answered scrapes. An empty Grafana password failed before startup, and the +ordinary Compose profile parsed without that password or either optional service. +Stopping isolated ingestion produced Down and unknown readiness. Restoring it +recovered readiness. Restarting both optional services preserved an earlier +Prometheus query result, the Grafana account and the provisioned dashboard. +Anonymous dashboard access returned 401. + +The real Grafana page passed at 1440, 390 and 320 pixels with no browser errors +or horizontal overflow. Review caught a clipped phone guide and Grafana's default +green chart series; the final page has a short visible guide, expandable help and +explicit chart colours. A phone chart was inspected after its data/canvas loaded. +A forced query failure removed the old green status values and displayed Unknown. +The automated WCAG 2 A/AA and 2.1 AA scan reported zero violations in its rules. +This check does not replace a full assistive-technology assessment of Grafana. + +The first API attempt stopped in test setup because it selected a Python without +the database driver; the rerun used the project environment and passed. A broad +type check found an incorrect generator return annotation in an earlier schema +test, which was corrected. Initial Grafana startup exceeded the 30-second grace +period; the final allowance is 180 seconds, and automatic suggested-plugin +installation is disabled. Browser test selectors initially counted guide text +as status cards, and an accessibility attempt required an explicit browser +context. Both harness issues were corrected before the passing runs. + +The main installation now runs the tested service images and optional profile. +All four scrape targets are up, the authenticated provisioned dashboard loads, +and the application readiness probe passes. All 12 durable control records match +their pre-deployment fingerprint. No schema change was required. Remote CI and +physical host sleep/wake did not run; the owner still defers the latter. diff --git a/docs/design/observability/chart-phone.png b/docs/design/observability/chart-phone.png new file mode 100644 index 0000000..2cd0db9 Binary files /dev/null and b/docs/design/observability/chart-phone.png differ diff --git a/docs/design/observability/index.html b/docs/design/observability/index.html new file mode 100644 index 0000000..b9a509f --- /dev/null +++ b/docs/design/observability/index.html @@ -0,0 +1 @@ +Service health design
Service health designIllustrative data
diff --git a/docs/design/observability/query-error.png b/docs/design/observability/query-error.png new file mode 100644 index 0000000..60ae3ae Binary files /dev/null and b/docs/design/observability/query-error.png differ diff --git a/docs/design/observability/screens.js b/docs/design/observability/screens.js new file mode 100644 index 0000000..0ea2742 --- /dev/null +++ b/docs/design/observability/screens.js @@ -0,0 +1,22 @@ +const services = ['Model', 'Gateway', 'Ingestion', 'API database']; +const descriptions = { + normal: 'All four endpoints answered. Each service reported that it can do its work.', + warming: 'The services are starting. Rates need at least two samples.', + service_down: 'The gateway endpoint did not answer. Its readiness and current charts are unknown.', + dependency_down: 'Ingestion can answer metrics while it waits for the database. Its last commit gets older.', + stale: 'The API readiness probe has not completed in the last 30 seconds.', + empty: 'No samples exist in this time range. Choose a recent range or check the targets.', + error: 'Grafana could not query Prometheus. Check the connection before using these panels.', + permission: 'Sign in to view service metrics. This login belongs to Grafana.', +}; +function draw() { + const state = document.querySelector('#state').value; + const absent = ['empty', 'error', 'permission'].includes(state); + const reach = services.map((name, i) => absent ? 'Unknown' : state === 'service_down' && i === 1 ? 'Down' : 'Reachable'); + const ready = services.map((name, i) => absent || (state === 'service_down' && i === 1) || (state === 'stale' && i === 3) ? 'Unknown' : state === 'warming' || (state === 'dependency_down' && i === 2) ? 'Not ready' : 'Ready'); + const card = (name, value) => `

${name}

${value}

`; + const chart = (name, unit, detail, line) => `

${name}

${detail}

${absent || state === 'warming' ? `

${state === 'error' ? 'Query failed' : state === 'permission' ? 'Sign in required' : 'No samples'}

` : `${unit}`}
`; + document.querySelector('#workspace').innerHTML = `

Service health

Reachability checks the metrics endpoint. Readiness checks whether the service can do its work. Missing samples stay unknown.

Refresh every 15 seconds

${descriptions[state]}

Can we reach each service?

${services.map((name,i)=>card(name,reach[i])).join('')}

Can each service do its work?

${services.map((name,i)=>card(name,ready[i])).join('')}

Pipeline activity

${chart('Committed readings', 'readings/s', 'Complete reading batches committed to the historian.', 'M25 90L100 80L180 82L260 75L340 77L420 71L490 76')}${chart('Time since last commit', 'seconds', 'Age increases after the last successful batch. An absent timestamp stays unknown.', state === 'dependency_down' ? 'M25 110L490 35' : 'M25 110L100 100L105 115L180 100L185 115L260 100L265 115L340 100L345 115L420 100L425 115L490 105')}

Model and API

${chart('Simulation lag', 'seconds', 'Delay before the model’s tick pass.', 'M25 115L160 112L240 90L270 113L490 115')}${chart('API request duration', 'seconds', 'The 95th percentile over five minutes. Sparse traffic may have no samples.', 'M25 100L140 90L250 94L340 85L490 95')}
Automation

Scheduled job and control outcomes use separate charts. Check the job state in the well dashboard when a scheduled attempt fails. A scheduler scan older than 90 seconds needs attention.

How to read missing data

Empty event series can mean that no event has occurred. A stopped target or query error can also leave a gap. Check reachability and the time range first. Open a panel’s Inspect menu to read its values as a table.

`; +} +document.querySelector('#state').addEventListener('change', draw); +draw(); diff --git a/docs/design/operating-workspace.md b/docs/design/operating-workspace.md new file mode 100644 index 0000000..7921f11 --- /dev/null +++ b/docs/design/operating-workspace.md @@ -0,0 +1,158 @@ +# Live control and parameter screens + +Date: 2026-10-17 + +## Purpose and scope + +Let an operator propose and review a change to the running synthetic well, and +let an administrator inspect and publish model parameters. The existing server +lifecycle remains authoritative. This adds human-facing screens, bounded recovery +reads and a parameter catalogue. It does not change the equations or schema. + +## User flow + +Controls appears for control:issue; Parameters appears for asset:manage. The +shared capability matrix supplies both decisions. ADMIN does not inherit +operating authority. Direct links still require server authorization. + +Controls shows the running revision and values, the durable revision and any +queued command. One form changes one target at a time: choke percentage, master +valve, wing valve, pump or shutdown/reset. A reason is required. Saving creates +an immutable draft. A separate review panel shows the well, target, reason, +expected revision, expiry and status. The creator must check a confirmation +before activation. Cancellation remains available while a control proposal is a draft or queued. +The worker can commit first, so a cancellation response must show the recorded +status rather than promise to undo an applied command. Parameter proposals can +be cancelled only while draft. Shutdown/reset occupies its own command, matching the API contract. + +A queued response means the worker has work to do. Applied means the database +committed the controls. The running revision and last successful tick show model +adoption separately. An expired or rejected command needs a new proposal. Never +silently replace a draft revision when status polling observes newer controls. + +Parameters shows the Python catalogue: description, unit, bounds, base default +and running value. Published overrides and their revision identifiers come from +the database. Administrators select a parameter, enter a value or remove its +database override, give a reason, and save a draft. Review precedes publication. +Removal restores environment/default/per-well resolution, not necessarily the +literal base default. Publication requires a service restart before existing +models adopt it. The screen does not restart infrastructure. + +## System and contracts + +The browser uses authenticated API routes. The API keeps service credentials +private and checks current account authority inside transactions. Existing +writes retain idempotency keys, revision checks, bounded deadlines and audit. +Use Hono validators with shared request schemas so browser calls stay typed. + +Add bounded recent-proposal reads for each well, ordered newest first with +stable identifiers, pagination and an optional own idempotency-key lookup. +These reads work without the model so a lost response remains recoverable. +Show the user's proposals; other creators' drafts cannot be activated here. +Existing single-proposal reads provide status and event history. + +Add an authenticated internal parameter-catalogue read. Python derives it from +ModelDefaults and the running ModelParameters. The API validates its response +with a shared schema before sending it to the browser. Do not duplicate the +parameter list or numeric limits in TypeScript. A stopped or invalid model can +still expose definitions and null running values so administrators can repair it. + +## Errors, accessibility and refresh + +Use visible labels, units, explanatory text, linked field errors and focused +result/error summaries. Reuse the dashboard panels, type, spacing and focus +styles. Stack the editor before review/history on narrow screens. Tables alone +may scroll inside named keyboard-focusable regions. + +After an uncertain write, keep its idempotency key, stop submission and offer +Check outcome. Read by that key or proposal ID; never replay the POST. A reload +can recover from Your proposals. Authentication or permission loss clears +restricted reads. Read failures retain marked data but disable activation from +unverified state. Revision conflict asks the user to refresh and review again. + +Parent status refresh remains ten seconds. Selected queued-command status can +refresh on the same cadence. Keep the selected status and permission check on this cadence, including terminal +results, so revoked access clears the view. Do not add a second route-wide polling timer. +A pending request disables duplicate buttons. Hidden tabs pause automatic reads. + +## Alternatives + +A direct slider would hide the review and audit boundary. Hand-entered parameter +keys would avoid a catalogue endpoint but force users to guess units and bounds. +The chosen forms use the existing lifecycle and authoritative parameter metadata. +A general administration console is beyond this screen's purpose. + +## Design checks + +Check 1 traced the proposal, activation, cancellation, parameter and current-user +routes through shared schemas, control-service transactions and Python resolution. +It found two gaps: missing proposal recovery lists and missing parameter metadata. +It also confirmed separate administrator/operator permissions, creator ownership, +24-hour draft expiry and restart-only parameter publication. The design above +addresses those gaps without new SQL or a duplicate parameter source. + +Check 2 passed 57 applicable screen/state combinations at 1440, 390 and 320 +CSS pixels before application code changes. It checked headings, reflow, +keyboard help, disabled stale inputs and restricted forms. Phone screenshots +were inspected. Review replaced the cramped proposal table with stacked cards +and corrected the illustrative parameter bounds against the Python defaults. +The first harness attempt referenced a non-existent package path; the next +checked the fieldset instead of its input. The corrected browser checks passed. + +## Verification + +Exercise the real API middleware with reader, operator and administrator users. +Check bounded history, another user's proposals, revocation, invalid keys and +catalogue errors. Test Python catalogue authentication, actual metadata and null +running values. Browser tests cover keyboard/forms, draft review, no replay, +conflicts, permissions, mobile layouts and explicit restart wording. Use an +isolated stack for actual command and parameter publication. Review and commit +only after these checks pass; record unrun gates explicitly. + +## Verification on 2026-10-17 + +Both final browser workflows passed in 3.3 minutes. They covered the existing +workspace accessibility flow and the new operating screens: linked validation, +review focus, stale revision protection, uncertain draft/activation responses, +GET outcome recovery without another write, queued cancellation, permission +loss, parameter publication and 320px reflow. Axe found no violations for the +selected WCAG A/AA rules in the tested states. These checks used Chromium and +do not establish full accessibility conformance. + +A separate browser used actual OPERATIONS and ADMIN accounts in the isolated +installation. A reviewed command advanced the revision exactly once, reached +the running model and appeared in the historian. Reload preserved its record. +The check restored the original control target. Parameter publication left the +running value unchanged; restart adopted it. Removing the override and restarting +restored the original value. The main installation and its controls were untouched +by that test. + +The 15 model HTTP tests passed, including authenticated catalogue reads and +definitions for a model without running parameters. Python lint, formatting and +type checks passed. The first API pass passed 14 tests. A further concurrency +review added a regression for proposal recovery during a slow parameter read and +for authority changes while that read waits. All 15 API tests then passed. +All six workspace type tasks and all six lint tasks passed, with two cached +tasks in each run. Both OpenAPI documents and the prose/diff checks passed. + +Review corrected a shared-layout focus conflict, scroll spacing under the fixed +header, persistent errors after successful outcome discovery, stale proposal +actions and parameter-read locking. The first operating browser attempt failed +its proposal-focus assertion; the corrected final workflow passed. Queued +cancellation required a design correction after checking the existing server +lifecycle. Its revised desktop and phone mockups passed before the screen change. +No schema change or numbered migration was needed. + +Final image checks captured the desktop control and parameter screens and the +phone control screen. The focused proposal heading cleared the fixed header; +the confirmation label measured at least 44px high. Saving and cancelling the +capture draft preserved the control revision. The tested images then replaced +the local API, model and dashboard containers. All six services reported healthy. +Stored control records matched exactly before and after the restart. A browser +confirmed login, 12 current wells, 216 readings, preserved running revisions and +choke targets, and the reader restriction on the control page. + +Deployment helper attempts first failed because `python` was unavailable and +then because a nested SQL quote was invalid. Neither attempt changed containers. +Passing SQL on standard input with Python 3 succeeded. The browser helper first +used the system Node 18, which Playwright rejected; the Node 24 run passed. diff --git a/docs/design/operations/index.html b/docs/design/operations/index.html new file mode 100644 index 0000000..ae55cfa --- /dev/null +++ b/docs/design/operations/index.html @@ -0,0 +1,12 @@ + + + +Operating screens design + + + + +
Operating screens designIllustrative data + +
+
diff --git a/docs/design/operations/screens.css b/docs/design/operations/screens.css new file mode 100644 index 0000000..a8c0628 --- /dev/null +++ b/docs/design/operations/screens.css @@ -0,0 +1,3 @@ +label:has(input[type="checkbox"]) { display: flex; align-items: center; gap: 12px; min-height: 44px; } +input[type="checkbox"] { width: 20px; height: 20px; min-height: 20px; flex: 0 0 20px; } +fieldset { min-width: 0; border: 0; padding: 0; } diff --git a/docs/design/operations/screens.js b/docs/design/operations/screens.js new file mode 100644 index 0000000..22ce920 --- /dev/null +++ b/docs/design/operations/screens.js @@ -0,0 +1,33 @@ +/* Review fixtures only. No model requests or live commands. */ +const screen = document.querySelector('#screen'); +const state = document.querySelector('#state'); +const query = new URLSearchParams(location.search); +screen.value = query.get('screen') || 'controls'; +state.value = query.get('state') || 'ready'; +const panel = (title, body) => `

${title}

${body}
`; +function render() { + const parameters = screen.value === 'parameters'; + const status = state.value; + history.replaceState(null, '', `?screen=${screen.value}&state=${status}`); + const disabled = ['loading', 'stale', 'uncertain'].includes(status) ? 'disabled' : ''; + let content = `

WH-001 · ${parameters ? 'Parameters' : 'Controls'}

${parameters ? 'Inspect model inputs and publish a change for the next service restart.' : 'Propose a change to this running well, then review it before activation.'}

`; + if (status === 'permission') { + document.querySelector('#workspace').innerHTML = content + panel('Access required', `

Your account needs ${parameters ? 'asset management' : 'operating'} permission for this view.

Return to Monitoring`); + return; + } + if (status === 'loading') content += '

Reading the latest state…

'; + if (status === 'stale') content += '

We could not verify these values. Refresh before submitting.

'; + if (status === 'error') content += '

The revision changed. Read the latest state and create a new proposal.

'; + if (status === 'uncertain') content += '

We did not receive the outcome. Check your proposal before trying another change.

'; + content += `
How this works
  1. Choose one ${parameters ? 'parameter' : 'control'} and explain why you want to change it.
  2. Save a proposal and check its values.
  3. ${parameters ? 'Publish it. A service restart must adopt the new parameters.' : 'Confirm and activate it. Check the command status and running revision.'}

Saving a draft does not change the running well. A missing response does not prove that a write failed.

`; + content += panel(parameters ? 'Parameter values' : 'Installed controls', parameters + ? '

Downstream pressure · psi

Base default: 250. Running value: 250.

Permitted values: 50 to 1,000 psi. No database override.

' + : '

Choke: 100%. Master and wing valves: open. Pump: running. Shutdown: normal.

Durable revision: 2. Running revision: 2. Last successful tick: 10:00:01 UTC.

'); + content += panel('New proposal', `
Choose a target
${parameters ? '

This restores environment/default resolution, which may differ from the base default.

' : ''}
`); + if (['review', 'queued', 'applied', 'stale'].includes(status)) content += panel('Review proposal', `

${parameters ? 'Downstream pressure: 210 psi. Expected override: none.' : 'Choke opening: 80%. Expected revision: 2.'}

Reason: Check response to a small change.

Expires: 18 Oct 2026, 10:00 UTC.

${status === 'queued' ? 'Queued. Waiting for the model worker.' : status === 'applied' ? parameters ? 'Published. Existing models need a service restart to adopt this change.' : 'Applied in the database at revision 3. Check the running revision for adoption.' : 'Draft. The running model has not changed.'}

${(['review', 'stale'].includes(status) || (status === 'queued' && !parameters)) ? `

${status === 'queued' ? 'Cancellation can lose a race with application. Check the returned status.' : ``}

` : ''}`); + content += panel('Your proposals', status === 'empty' ? '

You have no proposals for this well.

' : '

Check response to a small change

17 Oct, 10:00 UTC · Draft

'); + document.querySelector('#workspace').innerHTML = content; +} +screen.addEventListener('change', render); +state.addEventListener('change', render); +render(); diff --git a/docs/design/reference-well-physics.md b/docs/design/reference-well-physics.md index f931076..82acd9c 100644 --- a/docs/design/reference-well-physics.md +++ b/docs/design/reference-well-physics.md @@ -104,9 +104,9 @@ or negative result. The gateway rejects a missing, nonfinite, negative, stale, or mixed-tick field before changing any holding register. Ingestion writes a batch only after the Modbus heartbeat is stable. Values are encoded as float32, so historian precision is limited by the wire format. The fleet snapshot carries -model version 0.3.0, but the present Modbus and historian row format carries -only the source kind and tick time. Model-version lineage per stored reading -remains a persistence gap for Milestone 7. +model version 0.3.0. Since 2026-10-02 the gateway publishes that version beside +the Modbus heartbeat, and each stored reading records it in `model_version` +(see the ADR 0036 amendment). Readings stored earlier have no recorded version. ## Verification and standards boundary diff --git a/docs/design/scheduled-forecasts.md b/docs/design/scheduled-forecasts.md new file mode 100644 index 0000000..d9dcf22 --- /dev/null +++ b/docs/design/scheduled-forecasts.md @@ -0,0 +1,231 @@ +# Scheduled forecasts + +Date: 2026-10-17 +Status: Implemented and verified locally +Related decisions: [ADR 0013](../adr/0013-prediction-strategy.md), +[ADR 0033](../adr/0033-role-model-and-access-control.md) + +## Purpose + +Keep a recent short forecast available without asking someone to press Run. +The Forecast page keeps its on-demand form and gains a scheduled-forecast panel. +Readers can see whether the schedule is enabled, the next due time, the latest +attempt and the latest completed result. Administrators can create or change the +schedule. New installations start with every schedule disabled. + +The panel names the result's creation time and age. A failed or overdue attempt +does not make an older result current. Opening the result reads its existing URL; +it never calculates another forecast. A result records whether a person requested +it or a schedule created it. Saved runs show that distinction too. + +## Configuration and authority + +One schedule belongs to one well. It has an owner, enabled flag, interval, forecast +duration, revision, next due time, update time and reason. Administrators use the +existing `asset:manage` capability to edit it; all dashboard readers can inspect +it. The editor must confirm the reviewed settings and supply a reason. A revision +check rejects changes made against an older copy. An uncertain write leads to a +GET status check, without automatic replay. + +Intervals are whole minutes from 5 to 1440; duration is 5–30 whole minutes. The +worker saves a chart point every 60 seconds. Defaults are a 15-minute interval +and a 15-minute duration. Saving an enabled schedule starts a new interval from +the database clock. Disabling stops future claims; a calculation already in +progress may finish. The page explains both effects before submission. + +The administrator who saves the settings becomes their owner. A schedule persists +through logout, but the worker rechecks the owner's active ADMIN authority before +claiming and before saving a result. Revoked authority disables future work and +records the reason. The existing narrow `control_actor_authority` SQL function +already provides a locked authority read without exposing password columns. +Its name remains for compatibility; its returned role/status values support this +check. The service never borrows a browser token or invents a human click. + +## Durable records + +Add ordinary tables to `data/sql/schema.sql`: + +- `forecast_schedule`: UUID key, unique well, owner, settings, revision, due time, + reason and update time. +- `forecast_job`: UUID key, schedule/revision, copied owner/well/settings, + scheduled time, start time, lease expiry, missed-interval count, status, + completion time, reason code and optional completed prediction run UUID. + +A job is unique by schedule and scheduled time. A partial unique index allows +only one running job per schedule. Checks bound all settings and require a run +identifier exactly when status is complete. Terminal jobs have completion times; +running jobs do not. The public API can read jobs but cannot rewrite their results. +The model worker can advance due times, claim and finish jobs. Neither new path +can write live controls. + +A result still uses `prediction_run` and `prediction_point`. Its existing base-state +JSON gains `executionOrigin`, with scheduled job, schedule and due-time identity. +The job copies the settings used even if an administrator later changes the +schedule. The result keeps its full state, parameters, controls and alarm rules. +Legacy results without this metadata are manual, their only earlier source. +Run attribution identifies the schedule owner; execution metadata identifies the +worker action. The central audit records settings changes; attributed job records retain worker +outcomes. The worker receives no general audit-table or user-table read grant. + +## Claim and execution logic + +The scheduler runs in its own thread, outside the one-second model tick loop. +It checks for work every second and processes one job at a time. It shares the +existing two-slot projection capacity with on-demand requests. Readiness failures, +capacity exhaustion and calculation errors produce explicit job outcomes. +They do not retry the same scheduled job automatically. + +Read one candidate identity, then use a short transaction to lock its owner +first and its still-matching schedule second with `FOR UPDATE SKIP LOCKED`. +The owner-first order matches API writes and avoids a user/schedule deadlock. +Recheck owner identity, enabled state and due time under the schedule lock. PostgreSQL recommends this behavior for queue consumers; it avoids +waiting on a row another worker already claimed. Queries use stable ordering, +a limit of one and the existing database statement/lock timeouts. + +Let `n` be the database clock, `d` the stored `next_due_at`, and `i` the schedule's +`interval_seconds`. If `n >= d`, calculate: + +- `m = floor((n - d) / i)`, the missed interval count. +- `scheduled_for = d + m * i`, the most recent due slot. +- `next_due_at = scheduled_for + i`, the first future slot. + +All durations above use seconds; stored instants use UTC. The claim stores `m` +with one job and advances the schedule in the same transaction. There is no loop +that reconstructs every missed interval. A backwards clock movement leaves +future due times in place until wall time catches up. + +A claim lasts 60 seconds. The worker copies fresh running state, keeps current +controls, and calculates the configured bounded forecast. Saving run metadata, +all points and job completion happens in one transaction. Before inserting, lock +the owner first, then the job, and recheck its running state, unexpired lease +and owner authority. The +job UUID is the claim identity and is never reassigned to another attempt. + +If the process dies after claiming, a bounded cleanup marks expired running jobs +failed. It never replays them. A worker that finishes after expiry cannot commit a +result. If commit succeeds but the response is lost, the job and complete result +already agree; cleanup will not rewrite them. Schedule edits affect later claims; +a claimed job retains its copied settings. + +## API and UI + +`GET /twin/wellheads/{id}/forecast-schedule` returns the current schedule or null, +the latest job and the latest completed scheduled result summary. Queries return +only metadata. `PUT` accepts expected revision, enabled flag, interval/duration, +reason and explicit confirmation. The server rechecks authority inside the write +transaction. Conflict returns 409; uncertain dependency failures return 503. +No automatic write retry occurs. + +The panel shows Disabled, Scheduled, Running, Overdue or Attention required using +text. It reports missing results separately from a failed latest attempt. History +and saved result headings identify Scheduled forecast. The administrator editor +sits in an expandable section. Readers see an explanation of who manages settings +without a disabled form filling the page. + +Mockups cover reader and administrator views, disabled/empty, scheduled, running, +complete, overdue, failed, loading, stale, error and revoked permission. Reuse +shared tokens, labelled native controls, keyboard focus and 44px targets. State +refresh uses the existing ten-second parent refresh; writes retain entered values +and a captured revision. Failed reads disable writes from unverified state. + +## Schema delivery + +Use the [verified schema extension](schema-extension.md). Keep one SQL source and +one generated contract. Add only new ordinary tables and their indexes; no numbered +migration files, replay of old DDL, or replacement of the historian volume. +Verify a restored database copy before extending the main installation. + +## Design checks + +Check 1 traced the current forecast worker boundary, projection persistence, +capability matrix, account-lock helper, schema grants, installer contracts and +parent refresh. It found that forecasts require human attribution and that saved +runs already retain complete inputs. The design therefore keeps an explicit +schedule owner and execution origin, rechecks authority, and saves job completion +with the result. Reusing the locked authority reader avoids a broader user grant. + +Check 2 passed 60 combinations of role, state and viewport (1440, 390 and +320 CSS pixels), covering keyboard confirmation, reader restrictions, reflow and +stale/error gates. Phone rendering was inspected. A separate calculation checked +21 timing cases across three intervals, including exact boundaries and 30-day +gaps. Every due case yielded one slot and a next slot after the database clock. + +The second logic review found and removed a lock-order cycle: workers must lock +the owner before the schedule/job, matching API account locks. Competing workers +recheck the candidate after that lock. Expired claims cannot commit or replay; +role loss prevents result persistence; edits leave claimed settings intact; a +lost commit response cannot separate a completed job from its result. A backwards +clock waits, and a shutdown leaves an unfinished claim to expire explicitly. + +## Verification plan + +Use real TimescaleDB tests for duplicate claims, atomic completion, owner +revocation, expired claims, conflicting edits, rollback and original-data +preservation during schema extension. Use an injected clock for interval +boundaries and no-backlog behavior. Browser tests cover role separation, revision +conflicts, uncertain writes, retained drafts, status/result distinction, phone +reflow and automated accessibility. Run a real enabled schedule in the isolated +stack, observe its saved result, then disable it. Review and commit after the +required checks, recording unrun gates. + +## References + +- [PostgreSQL 16 SELECT locking clauses](https://www.postgresql.org/docs/16/sql-select.html#SQL-FOR-UPDATE-SHARE) +- [PostgreSQL 16 explicit locking](https://www.postgresql.org/docs/16/explicit-locking.html) + +## Implementation verification on 2026-10-17 + +- The initial affected Python run passed 80 checks, including real database claims, + rollback, revoked ownership and expired jobs. The worker suite then passed 31 + checks with shutdown coverage. Separate database checks passed expiry during + result insertion and schedule edits after a claim. An edited or disabled schedule + leaves the claimed job's copied inputs intact. +- All 109 API tests passed through the restricted application database account. + The schedule tests cover reader/operator denial, bounds, explicit confirmation, + current authority, competing owners, revision conflicts and audit rollback. + The test harness now requires an application account, and CI supplies its own + disposable database credential. Fixture setup retains a separate administrator + connection. CI also runs the extension and forecast transaction tests. +- Forty shared DTO and page tests passed. Six package type checks and six lint + tasks passed; Python typing passed for 51 source/test files. Both OpenAPI + contracts validated, and the prose check reported no errors or warnings. +- The browser workflow passed role separation, uncertain-save GET recovery, + revision conflicts, retained drafts after failed reads, permission loss, + separate failed/latest-completed states, 320px reflow and automated WCAG checks. +- An actual enabled schedule in the isolated stack created one owned job and five + points, retained its execution identity, reopened through the saved-run page and + left live controls and their revision unchanged. The schedule was disabled after + the check. Root installations keep schedules unconfigured until an administrator + explicitly saves one. + +The real browser check found a wellhead row lock that exceeded the API role's +read-only asset permissions. The fix uses an ordinary metadata read while keeping +account and schedule locks. The restricted API suite and real worker check then +passed. The expiry test initially tried to alter a lease through the worker role; +that correctly failed. Its corrected fixture waits for the database clock to cross +the deadline and proves the final completion check rolls back all inserted points. + +Screenshots show an automatic result from the isolated reference well and a phone +failure-state fixture. Automated accessibility checks do not replace assistive +technology testing. Remote CI has not run for this commit; physical sleep/wake +verification remains deferred at the owner's request. + +### Visual checks + +The final browser workflow passed again after the phone confirmation labels were +aligned with their checkboxes. Desktop and phone captures were inspected. + +- [Automatic forecast on the isolated reference well](../screenshots/scheduled-forecast.png) +- [Phone failure-state fixture with an older completed result](../screenshots/scheduled-forecast-mobile.png) + +### Main installation + +The tested API, model, dashboard and installer images were deployed locally. +The installer verified the extended schema and provisioned the restricted roles. +The browser smoke check found 12 model wells, an unconfigured schedule and the +expected reader permissions. Before/after fingerprints matched for all 12 durable +control-state rows. Scheduled jobs remain off until an administrator enables them. + +Job and run metadata remain available after a schedule stops. Forecast chart points +follow the existing retention policy; an old completed job does not guarantee its +points still exist. The saved-result endpoint reports expired points explicitly. diff --git a/docs/design/scheduled-forecasts/index.html b/docs/design/scheduled-forecasts/index.html new file mode 100644 index 0000000..e786842 --- /dev/null +++ b/docs/design/scheduled-forecasts/index.html @@ -0,0 +1 @@ +Scheduled forecast design
Scheduled forecast designIllustrative data
diff --git a/docs/design/scheduled-forecasts/screens.js b/docs/design/scheduled-forecasts/screens.js new file mode 100644 index 0000000..f11c999 --- /dev/null +++ b/docs/design/scheduled-forecasts/screens.js @@ -0,0 +1,3 @@ +const role=document.querySelector('#role');const state=document.querySelector('#state'); +function render(){const s=state.value;const admin=role.value==='administrator';const blocked=['loading','stale','error','permission'].includes(s);const labels={disabled:'Disabled',scheduled:'Scheduled',running:'Running',complete:'Scheduled',overdue:'Overdue',failed:'Attention required',loading:'Loading schedule…',stale:'Schedule could not refresh',error:'Schedule unavailable',permission:'Access changed'};document.querySelector('#workspace').innerHTML=`

WH-001 · Forecast

Forecast with current controls

Run a forecast now or inspect the latest scheduled result.

Scheduled forecasts

${labels[s]}

${s==='disabled'?'

No automatic forecasts. You can still run one below.

':s==='loading'?'

Reading schedule settings and recent attempts.

':s==='permission'?'

Your account cannot manage this schedule. Refresh your session to check access.

':s==='error'?'

We could not read the schedule. Check again before making a change.

':'

Every 15 minutes · Calculate 15 minutes ahead · Save a point each minute.

Next due: Oct 17, 2026, 11:15 AM (Africa/Lagos).

'}${s==='overdue'?'

The next run is overdue. Check model health; the previous result keeps its original time.

':''}${s==='failed'?'

Latest attempt failed: model state was not current. The worker will try at the next scheduled time.

':''}${['complete','failed','overdue','running'].includes(s)?'

Latest completed result: Oct 17, 2026, 10:45 AM. Created 20 minutes ago.

Open scheduled result':'

No completed scheduled result yet.

'}${s==='running'?'

A calculation is in progress. Disabling the schedule stops future work; this calculation may finish.

':''}${s==='stale'?'

The settings below may be out of date. Refresh before saving.

':''}${admin?`
Manage schedule

Saving restarts the interval. The administrator who saves becomes the schedule owner.

Interval: 5–1440 minutes. Duration: 5–30 minutes. These runs keep current controls.

`:'

An administrator manages scheduled forecasts.

'}

Run a forecast now

Your on-demand inputs and saved result stay here.

`;document.querySelector('#confirm')?.addEventListener('change',event=>{document.querySelector('#save').disabled=blocked||!event.target.checked;});} +role.addEventListener('change',render);state.addEventListener('change',render);render(); diff --git a/docs/design/schema-extension.md b/docs/design/schema-extension.md new file mode 100644 index 0000000..b1010ac --- /dev/null +++ b/docs/design/schema-extension.md @@ -0,0 +1,121 @@ +# Verified additions to the single SQL schema + +Date: 2026-10-17 +Status: Implemented and verified locally + +## Purpose + +Add new ordinary tables while preserving the user's existing historian. The +repository keeps `data/sql/schema.sql` as its single schema source, with a generated +catalog contract. New installations continue to apply that complete file. Existing +installations need an explicit, verified operation to add newly declared objects. + +## Input and allowed change + +An `extend` installer mode takes the previous schema source and generated contract +as temporary reference files. Obtain them from the installed commit before editing +the current source. They are not another maintained schema or a migration chain. +The current source must begin with the exact previous source text. The extension +is the remaining suffix, split by the existing SQL-aware statement parser. + +Initially support only `CREATE TABLE` for new ordinary public tables and +`CREATE INDEX` or `CREATE UNIQUE INDEX` on those new tables. Require simple +unquoted identifiers. Reject changes to existing declarations, data statements, +ALTER, DROP, function replacement, triggers, inherited/partitioned tables, +concurrent indexes and indexes on old tables. No automatic conversion of a +changed column or existing object is part of this operation. + +## Verification and transaction + +1. Hold the existing installer advisory lock. +2. Load both contracts against their exact source checksums. Require identical + engine versions and legacy-history definitions. +3. Verify the installed previous identity, history and complete catalog. If the + target is already installed, perform the normal check and return successfully. +4. Verify the target contract retains every old catalog definition. Validate the + suffix declarations and ensure each named table/index is new. +5. Apply only the permitted additions in one transaction with bounded lock and + statement timeouts. Existing tables keep their object identities and rows. +6. Compare the complete resulting catalog with the target generated contract. + A mismatch rolls back the additions. +7. Update schema-installation identity and record a successful extension audit + event in the same transaction. Provision the explicit service grants afterward. + +The new contract comes from applying the complete current SQL to a fresh database +on the pinned engine. It never comes from approving an unexplained changed live +catalog. Installation bookkeeping remains outside the application catalog and +retains its original init/adopt method and legacy history. + +## Failure review + +| Failure | Required result | +| --- | --- | +| Wrong previous source or contract | Refuse before DDL | +| Existing catalog drift | Refuse and keep the previous identity | +| Existing source declaration changed | Refuse; this mode only supports additions | +| DML, ALTER, DROP or old-table index in suffix | Refuse before DDL | +| New object name already exists | Refuse before DDL | +| Lock timeout or statement failure | Roll back every addition | +| Resulting catalog differs from generated target | Roll back additions and identity | +| Audit insert fails | Roll back additions and identity | +| Repeat after successful extension | Check the target and leave data unchanged | +| Service grant provisioning fails afterward | Structural identity remains valid; rerun provisioning | + +## Alternatives + +Replaying the whole file would collide with existing objects and could rerun +policies. Rebuilding the volume would discard data. Numbered migrations contradict +the chosen maintenance workflow. General automatic schema diffing would need to +infer data conversions and destructive intent. This bounded addition path fits +the new schedule and alarm lifecycle tables while keeping those concerns explicit. + +## Design checks and verification + +Check 1 read schema_install, schema_engine, schema_sql, schema_catalog and the +existing lifecycle tests. It confirmed source-bound contracts, audited identity +changes, parser handling for quoted semicolons, and the advisory installer lock. +Check 2 traced each failure above through a single transaction and checked that +no supported declaration can target an old table. Old catalog rows must remain +present; the final catalog must match the freshly generated reference exactly. + +Tests must exercise real transactional rollback, repeat execution, drift refusal, +source-prefix refusal, parser rejection and preservation of data, old object IDs +and legacy history. Take a private backup and verify a restored copy before +extending the main local database. Report the actual restore and comparison +results. Keep the original volume throughout. + +## Verification on 2026-10-17 + +The extension, installer and transaction tests passed 26 checks. They cover +unsupported SQL, changed prefixes, quoted semicolons, catalog mismatch rollback, +audit failure rollback, repeat execution and preservation of existing rows, +object identities and legacy history. A fresh reference database generated the +new contract, which contains 46 application relations. + +A private 84,737,661-byte backup restored successfully into a disposable database. +Before/after row counts and row-content hashes matched for 3,511,963 existing +records across 24 tables, including the legacy ledger. The restored copy accepted +the extension and passed the complete target contract check. Its initial setup +attempt found TimescaleDB already present in the database template; the corrected +setup reused that extension and the same backup. The rehearsal database was then +removed; the private backup remains outside the repository. + +The isolated and main installations both accepted the verified extension while +retaining their existing volumes. The updated installer image also passed a +normal isolated startup check and provisioned the intended service grants. + +## Restore comparison follow-up, 2026-10-17 + +The alarm extension restore rehearsal found that PostgreSQL flattens the nested +AND produced by forecast duration `BETWEEN` checks when it parses a dump. The +catalog comparison now recognizes only that verified whole-expression shape, +retaining every bound and divisibility operand. Other structural differences +still stop installation. The [alarm verification record](alarm-lifecycle.md) +describes the finding, narrow comparison rule and regression checks. Keep using +the exact previous SQL and contract; do not edit the old schema to match a dump. + +Use serial `pg_restore` for the full TimescaleDB archive. Parallel restore can +load extension catalog rows before their dependencies. The disposable rehearsal +reproduced this failure, and the [official procedure](https://github.com/timescale/Tiger-Data-Docs/blob/main/src/content/docs/deploy/self-hosted/backup-and-restore/logical-backup.mdx) +explicitly excludes `-j`. Keep `timescaledb_pre_restore()` and +`timescaledb_post_restore()` around the restore as documented. diff --git a/docs/design/twin-core-milestone-4.md b/docs/design/twin-core-milestone-4.md index 97eccfb..567eed5 100644 --- a/docs/design/twin-core-milestone-4.md +++ b/docs/design/twin-core-milestone-4.md @@ -1,5 +1,9 @@ # Milestone 4: Twin-Core Service Design +This document records this milestone’s original scope and dated checks. See the +[current architecture](../architecture/overview.md) and [ADR index](../adr/README.md) +for later additions, including operating screens, historical scenarios and schedules. + ## Purpose Milestone 4 creates the service boundary for the future wellhead model. It does @@ -65,6 +69,14 @@ invalid model parameters. No invalid override silently falls back to a default. The scheduler uses monotonic time so wall-clock changes cannot move its deadlines. The model timestep is one second, as decided in ADR 0020. +Tick times are UTC instants that other services compare with their own wall +clocks, so they cannot come from the monotonic clock alone. That clock stands +still while the host sleeps. Before each pass, the runtime compares tick time +with the wall clock. A forward jump of more than 2 seconds is recorded as +skipped ticks and tick time moves onto the wall clock. A backward jump holds +ticks until the wall clock passes the last tick. The ADR 0020 amendment of +2026-10-03 records why. + ```text due = max(0, floor((now - next_due) / interval) + 1) run = min(due, max_catchup_steps) diff --git a/docs/design/twin-core-milestone-5.md b/docs/design/twin-core-milestone-5.md index 2acc789..e6fe310 100644 --- a/docs/design/twin-core-milestone-5.md +++ b/docs/design/twin-core-milestone-5.md @@ -1,5 +1,9 @@ # Milestone 5: Reduced-Order Wellhead Model +This document records this milestone’s original scope and dated checks. See the +[current architecture](../architecture/overview.md) and [ADR index](../adr/README.md) +for later additions, including operating screens, historical scenarios and schedules. + ## Purpose and boundary Milestone 5 gives each active well a deterministic process state. It models cause diff --git a/docs/design/twin-core-milestone-6.md b/docs/design/twin-core-milestone-6.md index 17bdb98..226bfc7 100644 --- a/docs/design/twin-core-milestone-6.md +++ b/docs/design/twin-core-milestone-6.md @@ -1,5 +1,9 @@ # Milestone 6: Model Telemetry Through Modbus +This document records this milestone’s original scope and dated checks. See the +[current architecture](../architecture/overview.md) and [ADR index](../adr/README.md) +for later additions, including operating screens, historical scenarios and schedules. + ## Purpose A well's pressure and flow now come from the running process model. The Modbus @@ -72,11 +76,15 @@ Both encoder and decoder use the shared `WORD_ORDER` constant and a round-trip test. The TCP header carries a transaction ID, protocol ID, length, and unit ID. It carries no measurement time or quality flag. -Register 1900 is a two-word unsigned UTC epoch second for the model tick. The -gateway writes zero to the heartbeat before changing any signal register, then -writes the new tick time after all signal writes complete. Ingestion reads that -register on every mapped Modbus unit before and after polling. It stores a batch -only if all units agree on the same nonzero tick in both reads, the tick advances +Register 1900 is a two-word unsigned UTC epoch second for the model tick. +Registers 1902, 1903, and 1904 hold the model version's major, minor, and patch +numbers (added 2026-10-02, see the ADR 0036 amendment). Together they form a +five-register status block. The gateway writes zero to the whole block before +changing any signal register, then writes the new tick time and version in one +write after all signal writes complete. Ingestion reads the block with one +request (offset 1900, quantity 5) on every mapped Modbus unit before and after +polling. It stores a batch only if all units agree on the same nonzero tick and +the same version in both reads, the tick advances beyond the last stored tick, and it is no more than twice the telemetry interval old (minimum five seconds). A value more than two seconds in the future is rejected. The second read detects a batch changed @@ -84,7 +92,9 @@ mid-poll, including a change on a different unit. The zero marks an update in progress or an incomplete write. `timestamp_utc` in `parameterReading` is the model tick time carried by the -heartbeat. `inserted_at` is the database insertion time. The new `source_kind` +heartbeat. `model_version` is the version carried beside it, such as `0.3.0`, +and is `NULL` for rows stored before migration 0008. `inserted_at` is the +database insertion time. The new `source_kind` column marks old rows as `synthetic_random_simulator` and new rows as `synthetic_reduced_order_model`. Historian reads include the source on each reading and report `mixed` when old and new values appear together. No @@ -99,6 +109,8 @@ measurement is presented as observed plant data. | Any mapped well is missing or degraded | The complete batch is rejected. | | A value is malformed or outside its basic physical range | The batch is rejected before any register write. | | A register write fails | Heartbeat remains zero; ingestion skips the incomplete image. | +| A gateway update lands during a poll | Ingestion re-reads the status block and polls the new tick, stopping at half the telemetry interval. Without a consistent read by then, it skips that interval. Retries are counted in `wellhead_poll_retries_total`. | +| A consistent Modbus read finishes after half the polling interval | Ingestion discards the late batch, reports unready, and tries again on the next scheduled poll. | | A Modbus read fails | Ingestion rejects the poll and reconnects; it does not insert a partial batch. | | Metadata changes | Both services reload it. The gateway clears its register image before using the new map. | | Database is offline | Ingestion retries with bounded backoff and reports unready. No local store-and-forward exists. | diff --git a/docs/design/twin-core-milestone-7.md b/docs/design/twin-core-milestone-7.md new file mode 100644 index 0000000..09cc0d7 --- /dev/null +++ b/docs/design/twin-core-milestone-7.md @@ -0,0 +1,426 @@ +# Milestone 7: Twin Persistence + +This document records this milestone’s original scope and dated checks. See the +[current architecture](../architecture/overview.md) and [ADR index](../adr/README.md) +for later additions, including operating screens, historical scenarios and schedules. + +Status: implemented and verified against the local Docker stack, 2026-10-12. + +## Purpose + +Twin-core keeps each well's active process state in memory. Before this +milestone, a restart lost that state. Every well started again from its +synthetic operating balance, reported as `cold_start_no_snapshot`, and lost +the water cut, corrosion, and blockage that had accumulated. + +A concrete case shows the goal. Twin-core runs from 08:00 and restarts at 10:00. +It now loads each well's state saved at about 09:59:30, +records that the model did not run for the missing time, and carries on. The +restart becomes a visible, recorded gap instead of a silent reset. + +The model stays synthetic and uncalibrated. Persistence makes it continuous; it +does not make it more accurate. + +## Decisions this design follows + +Already accepted, and not reopened here: + +- [ADR 0012](../adr/0012-state-storage.md): live state stays in memory, with + periodic snapshots in TimescaleDB. +- [ADR 0020](../adr/0020-model-tick-semantics.md): one-second fixed steps, and + 30-second state snapshots. +- [ADR 0022](../adr/0022-recovery-semantics.md): restore the latest snapshot, + catch up small gaps, record larger ones as recovery events, and report + `recovering` before `running`. +- [ADR 0024](../adr/0024-twin-persistence-schema.md): important fields as + columns, the full state as JSONB, time series as hypertables. +- [ADR 0026](../adr/0026-retention-and-compression.md): snapshots compressed + after 7 days and kept 90 days; recovery events kept 2 years. +- [ADR 0021](../adr/0021-model-parameterization.md): version-controlled defaults + with per-well database overrides. + +Decided on 2026-10-03 for this milestone, and recorded as amendments to ADRs +0021, 0022, and 0024: + +- Only the tables this milestone writes are created. +- After a long gap, slow damage is restored as saved and fast states settle + toward balance by how much time passed. +- Twin-core gets insert-only access to its two new tables. +- Per-well overrides are edited by SQL for now. +- The live control API, and its `control_command` table, move to a new + milestone after Milestone 8. + +## Scope + +**In:** + +- `twin_state_snapshot`, `twin_recovery_event`, and `wellhead_model_parameter` + tables, with retention and compression. +- A snapshot writer in twin-core that never blocks the tick loop. +- Restore on startup, including the graded settling rule below. +- Recovery events for restores and for the wall-clock jumps added on + 2026-10-03. +- A database-backed per-well override source replacing `NoDatabaseOverrides`. +- Metrics, readiness, the internal API, and the Drizzle mirror for the new + tables. + +**Out, and where it goes instead:** + +- `prediction_run` and `prediction_point`: Milestone 8, with forecasts. +- `scenario_run` and `scenario_point`: Milestone 8, with what-if scenarios. +- `control_command`: the new control milestone after Milestone 8. +- `twin_model_definition` and `wellhead_model_config`: not needed while the + code runs one model version. Each snapshot and reading records the version it + came from, which Milestone 8 can join to a definition table if one is added. +- An API or screen for editing overrides: the control milestone, which builds + the authenticated write path anyway. +- Showing recovery gaps on the dashboard: Milestone 9. + +## Data flow + +```text + every 30 s, latest only +tick loop ──fleet copy──> [one-slot mailbox] ──> snapshot writer ──INSERT──> twin_state_snapshot + │ ▲ + └── clock jump, restore outcome ──> [event queue, 100] ┘ ──INSERT──> twin_recovery_event + +startup: wellhead_model_parameter ──> resolve parameters ──> fingerprint + twin_state_snapshot (latest per well) ──> compatible? gap? ──> restored state +``` + +The tick loop only ever hands work over. It never waits for the database. + +## Schema + +All three tables are created by migration `0010_twin_persistence.sql`. +They reuse the `model_version_text` domain from +migration 0008, which accepts only `major.minor.patch`. + +### `twin_state_snapshot` (hypertable on `simulated_at`) + +| Column | Type | Meaning | +| --- | --- | --- | +| `snapshot_id` | BIGSERIAL | Row identity | +| `simulated_at` | TIMESTAMPTZ NOT NULL | Model tick time of the saved state | +| `wellhead_id` | INT NOT NULL, references `wellhead` | The well | +| `model_version` | `model_version_text` NOT NULL | Model that produced the state | +| `parameter_fingerprint` | TEXT NOT NULL | SHA-256 of the resolved parameters, see below | +| `status` | TEXT NOT NULL | Well status when saved, constrained to `running` | +| `tick_index` | BIGINT NOT NULL | Ticks since this process run started | +| `skipped_ticks` | BIGINT NOT NULL | Gap ticks recorded so far | +| `tubing_pressure_psi` | DOUBLE PRECISION NOT NULL | High-value column | +| `casing_pressure_psi` | DOUBLE PRECISION NOT NULL | High-value column | +| `flow_rate_bpd` | DOUBLE PRECISION NOT NULL | High-value column | +| `wellhead_temperature_f` | DOUBLE PRECISION NOT NULL | High-value column | +| `water_cut_percent` | DOUBLE PRECISION NOT NULL | High-value column | +| `choke_position_percent` | DOUBLE PRECISION NOT NULL | High-value column | +| `blockage_factor` | DOUBLE PRECISION NOT NULL | High-value column | +| `corrosion_factor` | DOUBLE PRECISION NOT NULL | High-value column | +| `state` | JSONB NOT NULL | The full process state and controls, as `to_payload()` returns them | +| `inserted_at` | TIMESTAMPTZ NOT NULL DEFAULT now() | Database arrival time | + +Primary key `(snapshot_id, simulated_at)`, as TimescaleDB requires the time +column in it. Index on `(wellhead_id, simulated_at DESC)` for the "latest per +well" read at startup. Compression segments by `wellhead_id`, orders by +`simulated_at DESC`, starts after 7 days. Retention drops chunks after 90 days. + +At one row per well every 30 seconds, 12 wells write 34,560 rows a day, about +3.1 million over 90 days. Restore reads the latest row, including when the +service has been stopped long enough for that row to be compressed. + +**Parameter fingerprint.** The resolved parameter values for the well, sorted by +key and written as compact JSON, then hashed with SHA-256 and stored as hex. +Two runs with the same model version but a changed override get different +fingerprints, so a snapshot made under other parameters is never restored +silently. + +### `twin_recovery_event` (hypertable on `occurred_at`) + +| Column | Type | Meaning | +| --- | --- | --- | +| `event_id` | BIGSERIAL | Row identity | +| `occurred_at` | TIMESTAMPTZ NOT NULL DEFAULT now() | When it was recorded | +| `wellhead_id` | INT NULL, references `wellHead` | The well; NULL for a fleet-wide event such as a clock jump | +| `event_type` | TEXT NOT NULL, check constraint | One of the types below | +| `model_version` | `model_version_text` NOT NULL | Running model version | +| `snapshot_simulated_at` | TIMESTAMPTZ NULL | The restored or rejected snapshot, when there is one | +| `gap_seconds` | DOUBLE PRECISION NULL | Missing model time | +| `details` | JSONB NOT NULL DEFAULT '{}' | Reason codes, such as which check a snapshot failed | + +Event types: + +| Type | When | +| --- | --- | +| `snapshot_restored` | A compatible snapshot was loaded | +| `cold_start_no_snapshot` | The well has no snapshot | +| `snapshot_incompatible` | Model version or parameter fingerprint differs; the well cold-starts | +| `clock_jump_forward` | The wall clock moved ahead of tick time (the 2026-10-03 fix) | +| `clock_jump_backward` | The wall clock moved back; ticks held | + +Retention 2 years (ADR 0026). No compression: the volume is a handful of rows +per restart. It is a hypertable only so the retention policy can apply. + +### `wellhead_model_parameter` + +| Column | Type | Meaning | +| --- | --- | --- | +| `parameter_override_id` | SERIAL PRIMARY KEY | Row identity | +| `wellhead_id` | INT NOT NULL, references `wellHead` | The well | +| `model_version` | `model_version_text` NOT NULL | Model version the value was set for | +| `parameter_key` | TEXT NOT NULL | A key from `model_defaults.json` | +| `value` | DOUBLE PRECISION NULL | The override; NULL means "back to the default" | +| `unit` | TEXT NULL | Must equal the parameter's unit; NULL only when `value` is NULL | +| `source` | TEXT NOT NULL | Where the value came from, such as `manual` | +| `changed_by` | TEXT NOT NULL | Who made the change, typed by hand | +| `reason` | TEXT NOT NULL | Why | +| `valid_from` | TIMESTAMPTZ NOT NULL DEFAULT now() | When it takes effect | + +Unique on `(wellhead_id, model_version, parameter_key, valid_from)`. Rows are +never updated or deleted: a change is a new row, and reverting is a row with +`value` NULL. The current override for a key is the newest row whose +`valid_from` has passed. A check constraint keeps `value` and `unit` both set or +both NULL. This keeps every past value, with who changed it and why, as the +typed record that stands in for an audit trail until the control milestone. + +### Access + +The twin role, created by the migrator, keeps its existing `SELECT` grants and +gains: + +- `SELECT` on `wellhead_model_parameter` and `twin_state_snapshot`; +- `INSERT` on `twin_state_snapshot` and `twin_recovery_event`, and `USAGE` on + their sequences. + +No `UPDATE` or `DELETE` anywhere, so twin-core can add history but never change +or remove it. Its reader connections stay forced read-only. Only the writer's +connection is opened without that setting. + +## Snapshot writer + +- Every 30 seconds of tick time, the tick loop copies the registry snapshot + (already an immutable tuple) and puts it in a one-slot mailbox. +- A single writer thread takes the copy and inserts one row per well with + running process state, in one transaction, with a 3-second connect and + statement timeout. +- If the writer is still busy when the next copy arrives, the older copy is + replaced, never queued. Each replacement is counted. The newest state is all + restore needs, so a replaced copy loses nothing that matters. +- A failed write is logged and counted, and the next copy is tried 30 seconds + later. There is no retry loop. +- On a clean shutdown (SIGTERM), the model loop stops before taking its final + copy. The shutdown path writes that copy directly using a separate bounded + database connection, so an older write or queued event cannot delay it. + A failed final write is logged and counted, and the process exits nonzero. + The background writer then gets up to 3 seconds to finish queued events. +- Recovery events go through a separate bounded queue of 100, written by the + same thread. Events are not replaceable, so an overflow is logged at ERROR + and counted rather than dropped quietly. + +## Restore on startup + +After the first fleet refresh, and before the first tick, twin-core reads the +latest snapshot for each active well and decides per well. + +1. **No snapshot:** cold start from the operating balance, as today. Event + `cold_start_no_snapshot`. +2. **Incompatible:** the model version or the parameter fingerprint differs. + Cold start. Event `snapshot_incompatible`, with the failed check in + `details`. +3. **Compatible:** let the gap $g$ be wall time now minus the snapshot's + `simulated_at`, in seconds. + - $g < 0$: the wall clock is behind the snapshot. Restore as saved and let + the existing hold logic wait until the wall clock passes it. + - $0 \le g \le 5$: restore as saved, then run $\lfloor g \rfloor$ real + one-second steps. This is the bounded catch-up ADR 0022 allows; 5 is the + existing `TWIN_MAX_CATCHUP_STEPS`. + - $g > 5$: restore using the settling rule below. Record $\lfloor g \rfloor$ + skipped ticks. + + Event `snapshot_restored`, with $g$ in `gap_seconds`. + +The well reports `recovering` until its first tick after restore, then +`running` with reason `restored_from_snapshot`. No readings are produced for +the gap, so the historian shows it as missing data. + +### Settling rule for gaps over 5 seconds + +The model did not run during the gap, but a real well would have kept going. +Its fast states would have moved toward balance, and its slow damage would have +kept accumulating at the rate it had. + +**Slow damage is restored exactly as saved:** `water_cut_percent`, +`corrosion_factor`, `blockage_factor`. They change over hours to days (water +cut drifts about 0.36 percentage points an hour), and they are what a snapshot +exists to keep. They are not advanced through the gap, because that would +invent model history the model never computed. + +**The balance is re-solved** with that restored damage and the current controls +and parameters. The solver is the one `initial_state` already uses. + +**Each fast state then moves from its saved value toward that balance** by the +fraction it would have settled in the gap: + +$$x_{\text{restored}} = x_{\text{bal}} + (x_{\text{saved}} - x_{\text{bal}})\, e^{-g/\tau}$$ + +| Symbol | Meaning | Unit | Where it comes from | +| --- | --- | --- | --- | +| $x_{\text{saved}}$ | The state in the snapshot | the state's unit | `twin_state_snapshot.state` | +| $x_{\text{bal}}$ | The same state at the re-solved balance | the state's unit | balance solver | +| $g$ | Gap | s | now minus `simulated_at` | +| $\tau$ | The state's time constant | s | table below | + +| Fast state | $\tau$ | Source in code | +| --- | --- | --- | +| Tubing pressure | $1 / \big(k\,(J + \partial Q/\partial P)\big)$, about 6 minutes | see below | +| Casing pressure | $1 / r_c$ = 50 s | `casing_response_rate` | +| Wellhead temperature | $1 / r_T$ = 100 s | `thermal_response_rate` | +| Annulus temperature | $1 / r_A$ = 200 s | `annulus_thermal_response_rate` | +| Pump flow factor | $1 / r_p$ = 33 s | `pump_response_rate` | + +For tubing pressure, the model's pressure step is +$P_{n+1} = P_n + \big(k\,(J\,(P_r - P_n) - Q(P_n)) - L\big)\,\Delta t$. +Linearising around the balance gives the time constant above: + +| Symbol | Meaning | Unit | Code | +| --- | --- | --- | --- | +| $k$ | Pressure gain | psi per (bbl/day) per s | `pressure_gain_coefficient`, 0.002 | +| $J$ | Productivity index | bbl/day/psi | `productivity_index`, 1.2 | +| $\partial Q/\partial P$ | How fast choke flow rises with pressure at balance | bbl/day/psi | $C\,(u/100)\,f\,b \,/\, \big(2\sqrt{P - P_d}\big)$ | +| $C$ | Choke coefficient | bbl/day/√psi | `choke_coefficient` | +| $u$ | Choke opening | % | `choke_position_percent` | +| $f$ | Pump flow factor | dimensionless | `pump_flow_factor` | +| $b$ | Blockage factor | dimensionless | `blockage_factor` | +| $P_d$ | Downstream pressure | psi | `downstream_pressure` | + +Checked against the real model on 2026-10-03: the formula gives 360 s and +343 s for wells 1 and 7, and a direct step-response measurement gives 359 s and +342 s. A fast state is within 1% of balance after about $5\tau$: about +27 minutes for tubing pressure and under 17 minutes for the others. + +**Annulus pressure is not graded.** The annulus holds a fixed mass of gas, so +its pressure follows its temperature. It is recalculated from the restored +annulus temperature with the fixed-mass gas law the model already uses +(`annulus_next_pressure`). **Flow and the seven diagnostic readings** have no +memory of their own; they are recomputed from the restored state exactly as on +every tick. + +A pure function, `restore_state(saved, parameters, controls, gap_seconds, at)`, +returns the restored `ProcessState`. It holds no clock or database access, so +every branch can be tested directly. + +## Per-well overrides + +`DatabaseOverrideSource.load(wellhead_ids, model_version)` replaces +`NoDatabaseOverrides`. It reads the newest row per well and key where +`valid_from` has passed, drops rows whose `value` is NULL, and returns +`ParameterOverride(value, unit, source)`. The existing resolver then checks the +key, the unit, the source, and the range. The loader runs on every fleet +refresh (30 seconds), so a change is picked up within 30 seconds: + +- an invalid value marks that well `unavailable`, reason `invalid_parameters`, + and readiness reports it; +- a valid but changed value on a running well marks it `restart_required`, + which is the existing behaviour. Live behaviour never changes silently. + +An administrator edits overrides with SQL, as documented in `data/README.md`. +The `changed_by` and `reason` columns are typed by hand, so they are a record, +not a verified actor. That is accepted until the control milestone builds an +authenticated write path (ADR 0021 amendment). + +## Status, readiness, and the internal API + +- Well status gains `recovering`, between restore and the first tick. +- `GET /wellheads/{id}/state` gains `restoredFrom` (snapshot time or null) and + `lastRecoveryEvent` (type and time). +- Readiness is unchanged in rule: a well that is `recovering` is not running, + so readiness waits for one tick, about a second. + +## Failure behaviour + +| Condition | Behaviour | +| --- | --- | +| Database down at startup | Snapshot read fails. The fleet refresh already fails in this case, so the service is not ready and retries. It never cold-starts by mistake because a read failed. | +| Database down while running | Snapshot writes fail and are counted. Ticks continue. Restore after a restart uses the last snapshot that was saved. | +| Writer slower than 30 seconds | The pending copy is replaced and counted. Ticks never wait. | +| Event queue full | Overflow logged at ERROR and counted. | +| Snapshot from another model version or parameters | Not restored. Cold start, with the reason recorded. | +| Snapshot JSON fails validation | Treated as incompatible. The well cold-starts; the bad row is reported in `details`. | +| Saved state fails the model's bounds check | The snapshot is rejected as incompatible and the well starts cold. | +| Twin role lacks a grant | The migrator grants it before twin-core starts. A missing grant fails the write, which is counted and logged. | + +## Metrics + +- `well_sim_twin_snapshot_writes_total` and + `well_sim_twin_snapshot_write_failures_total`. +- `well_sim_twin_snapshots_replaced_total`, copies replaced before writing. +- `well_sim_twin_last_snapshot_age_seconds`. +- `well_sim_twin_recoveries_total{outcome}`, labelled with the event type. +- `well_sim_twin_recovery_events_dropped_total`. + +## Acceptance checks + +Each check is a test, unless it says "live". + +1. A restart less than 5 seconds after the last snapshot runs that many real + steps, records no gap, and continues the saved trajectory. +2. A restart after 10 minutes keeps water cut, corrosion, and blockage exactly + as saved. Tubing pressure is + $x_{\text{bal}} + (x_{\text{saved}} - x_{\text{bal}}) e^{-600/\tau}$, within + floating-point tolerance. 600 skipped ticks are recorded. +3. A restart after 9 hours leaves every fast state within 1% of balance, and + the damage is still as saved. +4. A changed override, or a new model version, prevents restore and records + `snapshot_incompatible` with the reason. +5. The tick loop's timing does not change while the database is unreachable. +6. A slow writer replaces pending copies and never blocks a tick. +7. The twin role cannot `UPDATE` or `DELETE` any table (tested against a real + database). +8. An invalid override marks only its well unavailable; a valid change marks a + running well `restart_required`. +9. Live: restart twin-core and see the wells resume, not cold-start, with a + `snapshot_restored` event and the gap visible in the historian. +10. Live: put the host to sleep and see a `clock_jump_forward` event recorded. + +## What this design deliberately does not do + +- **Replay the gap.** Running thousands of steps to fill a downtime would + present model history that was never computed. ADR 0022 rejects it. +- **Advance slow damage through the gap.** For the same reason. A long + outage shows up as recorded missing time, not as extrapolated wear. +- **Restore across model versions.** A newer model may read the same field + differently. A cold start with a recorded reason is safer than a guess. + +## Implementation and verification, 2026-10-12 + +Migration `0010_twin_persistence.sql` creates the three tables and retention +policies. `twin_core/persistence.py` reads snapshots and parameter overrides, +and moves writes to one bounded background worker. `twin_core/recovery.py` +checks saved JSON and applies the catch-up or settling rule. The runtime restores +each new well before its first tick. The state API now includes `restoredFrom` +and `lastRecoveryEvent`. + +The migration applied to the running TimescaleDB volume. The twin role could +insert snapshots and could not update them. On the first service start, all +12 active wells wrote a snapshot and a `cold_start_no_snapshot` event. A service +restart restored all 12, and the database recorded 12 `snapshot_restored` +events. Unit tests cover exact short-gap replay, long-gap settling, rejected +snapshots, future-time hold, and writer replacement. A real host sleep test +has not run; the earlier injectable-clock test covers the jump logic. +After a longer local stop, the live service restored all 12 wells, recorded +about 32 seconds of missing time, and returned to ready. + +## Shutdown correction, 2026-10-12 + +The first implementation queued the final copy on the background writer and +waited only 3 seconds. A slow earlier write could use that entire window, +leaving the final copy unsaved. The shutdown path now writes the final copy +directly after the tick loop stops. It uses the same 3-second connect and +statement timeouts as a periodic write. The older background writer can finish +its current work and queued events during the remaining 3-second join. +Failures of the final write are visible through the snapshot failure metric, +an error log, and a nonzero exit. The last successful periodic snapshot remains +available for recovery if the final write fails. + +Docker Compose gives twin-core 15 seconds after SIGTERM before forced stop. +This covers the 3-second connection limit, 3-second statement limit, +3-second background join, HTTP server close, and scheduling margin. diff --git a/docs/design/twin-core-milestone-8.md b/docs/design/twin-core-milestone-8.md new file mode 100644 index 0000000..bf02eb9 --- /dev/null +++ b/docs/design/twin-core-milestone-8.md @@ -0,0 +1,209 @@ +# Milestone 8: Forecasts and Isolated Scenarios + +This document records this milestone’s original scope and dated checks. See the +[current architecture](../architecture/overview.md) and [ADR index](../adr/README.md) +for later additions, including operating screens, historical scenarios and schedules. + +Status: implemented and validated against a disposable TimescaleDB database, +2026-10-13. + +## Purpose and scope + +The live twin updates each well once per second. Milestone 8 lets a signed-in +user ask what that same model would do over the next few minutes. A forecast +holds current controls fixed. A scenario changes specified controls on a +private copy, while a second private copy supplies an unchanged baseline. +Neither request sends a command to the live well. Live control is Milestone 8B. + +This is a synthetic reference well. Its parameters have not been fitted to +measurements from a physical well. A projection is useful for testing model +behavior and comparing synthetic control choices, not for operational decisions +at a real site. + +The work implements on-demand requests, four persistence tables, a bounded +heuristic confidence score, and projected alarm thresholds. Historical branching +and the dashboard presentation remain later milestones. + +## Decisions and alternatives + +[ADR 0013](../adr/0013-prediction-strategy.md) chooses on-demand forecasts first. +Scheduled forecasts would precompute data for a dashboard but add background +load before request behavior is established. [ADR 0014](../adr/0014-scenario-execution.md) +chooses isolated, saved scenarios. An unsaved scenario would use less storage but +would give operators no result to revisit. [ADR 0025](../adr/0025-forecast-horizon-confidence.md) +chooses a short horizon and heuristic confidence. Statistical intervals need +measured forecast errors, which this synthetic reference well does not have. +[ADR 0027](../adr/0027-historical-branching.md) defers historical starts. + +## Data flow and trust boundary + +```text +signed-in browser + | session cookie and JSON request + v +Hono API: capability check, limits, audit + | service token and numeric actor ID + v +twin-core: copy one running well under the registry lock + | state + parameters + controls + model version + v +private one-second model steps + active historian alarm rules + | complete run and all sampled points, one database transaction + v +TimescaleDB + | API reads saved run and every point by run ID + v +signed-in browser +``` + +Only the API knows the internal service token. The browser never calls the +twin-core write routes directly. The API checks the session and the existing +`scenario:run` capability for both creation routes. Saved reads require +`dashboard:read`. The internal service checks a constant-time token comparison +and the actor ID. It refuses requests without a current running well or a +recent model state. The current implementation limits active projections to two +and each HTTP body to 4096 bytes. API creation requests have a separate limit +of ten per client per minute. + +## Model and timing + +The forecast reuses `step_model` from Milestones 4 through 6. There is one +model, so future outputs and current telemetry share the same equations and +signal meanings. The projection copies a `ProcessState`, `ModelParameters`, and +`ControlInputs` together under the registry lock; it does not read these values +in separate calls while ticks advance. + +For each integer second after the copied state: + +```text +x[k+1] = F(x[k], u[k], p, t0 + (k+1) seconds) +``` + +`x` is the `ProcessState`, including pressure in psi, temperature in °F, flow in +barrels per day, and the other modeled quantities. `u` is `ControlInputs`, such +as choke opening in percent and valve booleans. `p` is the fixed +`ModelParameters` for the run. `t0` is `base.state.simulated_at`, the timestamp +of the copied state. `F` is the existing `step_model` function with a one-second +step. The request's `stepSeconds` is an output sampling interval, not a larger +integration step. This matters because stepping the equations in 60-second jumps +would change their behavior. + +Forecasts hold `u[k]` equal to copied live controls. For a scenario, an input +scheduled at `offsetMinutes = m` becomes active before the model step ending at +`t0 + (60m + 1) seconds`. Only five actuator-like fields may change: choke +opening, master valve, wing valve, pump, and emergency shutdown. Requested +pressure, flow, gas concentration, or damage outcomes are rejected. The baseline +uses the copied controls for every second, so scenario and baseline points have +the same starting state, parameter set, and timestamps. Their difference comes +only from the requested control schedule. + +The horizon `H` is 300 to 7200 seconds, default 1800. The sample interval `S` +is 5 to 300 seconds, default 60. `H` must be divisible by `S`; point count is +`H / S`, at most 1440. A scenario has 1 to 16 nonempty control changes at +strictly increasing whole-minute offsets before the horizon. These limits bound +CPU, memory, response size, and database rows. + +## Alarm risk and confidence + +Twin-core reads the active `alarmRule` rows and their `parameterType` codes once +per run. It evaluates each rule against the modeled value in that parameter's +canonical unit. The supported operators are `>`, `>=`, `<`, `<=`, and `==`, the +same operators used for historian alarms. A sampled point is `critical` if any +critical rule crosses, `warning` if only warning rules cross, `none` if no rule +crosses, and `unknown` if there are no active rules. The response includes rule +IDs that crossed. A scenario also reports the baseline risk. This is a +projected threshold state, never a probability that an alarm will occur. + +The 18 output keys match the existing historian `parameterType` codes and +units. Pressure keys (`tubing_pressure`, `casing_pressure`, +`annulus_pressure`) use psi. `flow_rate` uses barrels/day; +`gas_oil_ratio` uses standard cubic feet per stock-tank barrel. `sand_detector` +and `h2s_level` use parts per million; `corrosion_rate` uses mils per year; +`co2_level`, `choke_valve_position`, and `water_cut` use percent; +`vibration` uses millimeters/second; `wellhead_temperature` uses °F. +`master_valve_status`, `wing_valve_status`, `swab_valve_status`, +`emergency_shutdown`, and `pump_status` are 0 or 1. Choke position is rounded +to the integer value that the Modbus gateway publishes before comparing an +alarm threshold. A parity test compares all projected values with the gateway +conversion from the same model state. + +The confidence number is an explicit, uncalibrated heuristic for the whole run: + +```text +C = clamp(0.65 - 0.25 * H / 7200 - 0.10*R - 0.10*A - 0.10*U, 0.10, 0.65) +``` + +`C` is `confidence.score`, a dimensionless display score. `H` is +`horizonSeconds` in seconds. `R` is 1 when the copied model was recently +restored. `A` is 1 if any projected value lies within ten percent of an active +rule threshold, or if there are no active alarm rules. `U` is 1 if a scheduled +choke move exceeds 25 +percentage points or a binary actuator changes. The code records separate +reason strings for every deduction. The formula is implemented by `_confidence` +in `twin_core/projection.py`; the no-rule deduction is separate from the +near-threshold deduction. The label is `moderate` for scores at least 0.4 and +`low` otherwise. All results say `heuristic_uncalibrated` and +`fieldCalibrated: false`. No output calls this a confidence interval. + +The score cannot account for measured forecast error, parameter uncertainty, +unknown equipment faults, or changing external conditions. It must not be used +to rank real-world safety. A later calibration project needs field history and +backtesting before it can assign statistical meaning. + +## Persistence and retrieval + +Migration `0011_forecasts_and_scenarios.sql` adds ordinary `prediction_run` and +`scenario_run` tables. Each stores a UUID, well ID, requesting actor ID, model +version, parameter fingerprint, copied state, parameter values, control values, +active alarm rules, horizon, sample interval, and confidence result. Scenario +runs also store the name and scheduled control changes. These fields make a run +explainable after model defaults or alarm rules change. The actor ID references +`users(id)`, so a saved run cannot claim a nonexistent account. + +`prediction_point` and `scenario_point` are TimescaleDB hypertables partitioned +by `forecast_at`. Their primary key includes `(run_id, forecast_at)`, so it +contains the partitioning time. Each point stores all 18 canonical model signals +as JSONB, queryable tubing pressure (psi), flow (barrels/day), wellhead +temperature (°F), water cut (percent), and risk. Scenario points also store +baseline values and baseline risk. +This key shape follows +[TimescaleDB's unique-index rule](https://docs.timescale.com/use-timescale/latest/hypertables/hypertables-and-unique-indexes/). +The two point tables are compressed after 3 and 7 days and retained for 30 and +90 days respectively. Run metadata has no automated expiry. Once any point is +missing, the public read endpoint returns HTTP 410 rather than a truncated run. +This avoids pretending that a retained fragment is the original result. + +Twin-core writes the run and every point in one database transaction. If any +insert fails, PostgreSQL rolls the whole run back and the API returns an error. +The twin database role can read active alarm rules and insert projection rows; +it has no update or delete grant on these tables. The API uses the existing +database reader to load saved runs, ordered by sample time. + +## API and failure behavior + +The internal routes are `POST /wellheads/{wellheadId}/forecast` and +`POST /scenarios`; successful creation returns 201 with a run and all points. +The public routes are `POST /twin/wellheads/{wellheadId}/forecast`, +`POST /twin/scenarios`, `GET /twin/forecasts/{runId}`, and +`GET /twin/scenarios/{runId}`. The public API wraps successful data in its +standard envelope and returns HTTP 200. The OpenAPI contracts specify the +request and response fields. + +Invalid inputs return 400. Missing session or capability returns 401 or 403. +Unknown wells and runs return 404. An expired or incomplete saved run returns +410. A busy or unavailable model or database returns 503; the API gives its +internal call 12 seconds before a 504 timeout. The API records success or +failure of creation in its audit trail without recording the service token. +Twin-core counts runs by kind and outcome and measures their duration. + +## Verification and remaining limits + +Tests cover model isolation, control timing and direction, bounds, active-rule +projection, internal authentication and failures, public authentication and +saved reads, and migration application on a disposable Timescale database. +The final verification result belongs in the delivery report and should not be +inferred from this design alone. + +No dashboard forecast or scenario view is part of this milestone. No physical +well data was supplied for calibration or external validation. There is no +historical branch, background forecast scheduler, or live control command here. diff --git a/docs/design/twin-core-milestone-8b.md b/docs/design/twin-core-milestone-8b.md new file mode 100644 index 0000000..6aac53c --- /dev/null +++ b/docs/design/twin-core-milestone-8b.md @@ -0,0 +1,401 @@ +# Milestone 8B: Live controls for the synthetic well + +This document records this milestone’s original scope and dated checks. See the +[current architecture](../architecture/overview.md) and [ADR index](../adr/README.md) +for later additions, including operating screens, historical scenarios and schedules. + +Status: implemented and verified locally, 2026-10-13. + +## Goal and boundary + +An OPERATIONS user can ask the running synthetic well to use a new choke, pump, +valve, or emergency shutdown input. The request has a draft step and an explicit +activation step. A response says whether the command is drafted, queued, +applied, rejected, expired, or cancelled. It never calls a queued command +applied. + +This milestone changes the in-memory model and its durable active control row. +It does not write a Modbus coil or register. The current Modbus gateway reads +the model snapshot and publishes telemetry. Its status block at holding +register offsets 1900 to 1904 carries the model tick and version, not a +command. No wire frame or device register changes in 8B. Emergency shutdown +remains a model input; it is not a real well safety system. + +[ADR 0038](../adr/0038-live-control-command-lifecycle.md) records the command +decision. [ADR 0007](../adr/0007-control-and-what-if-boundaries.md) keeps live +controls separate from private scenarios. [ADR 0015](../adr/0015-control-and-safety.md) +names the five editable fields. + +## Starting point before 8B + +- ModelRegistry owns one ControlInputs value per active well under its lock. + Its tick method advances each well in one-second steps. +- A saved snapshot contains process state, including control values. Startup + restores those values. The snapshot writer runs every 30 seconds. +- The API checks a signed session and the control:issue capability, but its + role claim can outlive a role change until the user signs in again. +- The API's audit helper logs and swallows a failed audit insert. This is too + weak for a command that changes the active model. +- The Compose API uses the PostgreSQL administrator account. It must use a + scoped application account before this route is enabled. +- Forecast and scenario requests already use a separate internal service token. + Their routes do not change active controls. + +## Data flow + +1. The API checks the session and reads the user's current role and account + status from the database. Only an active OPERATIONS user may create, + activate, or cancel a control command. +2. Draft creation stores one proposal and a matching audit row in a transaction. + A draft has no model effect. +3. Activation requires a current running twin-core model, then locks the draft + and active control row. It compares the control + revision, model version, and parameter fingerprint. It stores queued status, + expiry, and audit in one transaction, then returns HTTP 202. +4. The API sends only the command UUID to twin-core over authenticated internal + HTTP. This is a wake-up hint. Twin-core also scans the durable queue once a + second, so losing the hint cannot lose the command. +5. Before a model tick, twin-core rechecks the queued command. A bounded + transaction moves the full target controls into the active row and records + applied status and an event. It then installs the same controls in memory + under the registry lock. +6. The model takes the next step with those controls. The API's status read + shows the durable result. The later dashboard may display it, but a new + dashboard control form is outside this milestone. + +The API owns human identity and draft activation. Twin-core owns model +validation and the point of application. PostgreSQL owns the accepted command +and active revision. Modbus remains a read path. + +## Control logic and units + +The command is a set of absolute targets for supplied fields. Missing fields +keep their current values. JSON null is invalid; it cannot mean unchanged. + +| API field | Model field | Unit and allowed values | +| --- | --- | --- | +| chokePositionPercent | choke_position_percent | Percent, finite 0 to 100 | +| masterValveOpen | master_valve_open | Boolean | +| wingValveOpen | wing_valve_open | Boolean | +| pumpOn | pump_on | Boolean | +| emergencyShutdown | emergency_shutdown | Boolean | + +Swab valve is part of the full model control state but cannot be changed by a +command. Pressure, flow, temperature, gas, and damage are model outcomes and +cannot be targets. + +For a command applied before tick k+1: + + u[k+1] = merge(u[k], c) + x[k+1] = F(x[k], u[k+1], p, t[k+1]) + +Here u is the full ControlInputs value. c is the validated command target, +using percent for choke and booleans for the other fields. x is ProcessState, +including pressure in psi, flow in barrels per day, and temperature in degrees +Fahrenheit. p is the fixed ModelParameters value for that well. t is the UTC +tick time. F is the existing step_model function, with a one-second step. +The command does not edit x directly. If the command reaches the worker just +after a tick starts, it waits for the next tick. + +An emergencyShutdown true command contains no other target field. While that +input is true, other control changes are refused. Reset is a separate command +whose only target is emergencyShutdown false. This rule avoids hiding a valve +opening inside a reset. It does not replace a physical emergency shutdown. + +## Schema and state machine + +Migration 0012 adds ordinary tables. SQL migrations remain canonical, and +Drizzle mirrors their shape. + +| Table | Important columns and rules | +| --- | --- | +| control_command | UUID key; well and creator foreign keys; reason; five nullable typed target columns; expected_revision; model_version; parameter_fingerprint; UUID idempotency_key; status; created, queued, expiry, and result times; activated_by | +| wellhead_control_state | One row per well; revision; all six typed controls; model_version and parameter_fingerprint; applied_command_id; applied_at | +| control_command_event | Append-only event ID; command ID; event type; time; human actor when present; bounded reason code | + +A unique constraint on creator plus idempotency key prevents a repeated draft +request from making two commands. A retry with the same key and different +body returns 409. Partial unique indexes allow one draft per creator and well, +and at most one +queued command per well. A stale expected revision returns 409. Database checks +enforce choke bounds and at least one target. No command table has automatic +retention; these rows are the operational history after the 90-day audit-event +retention window. + + draft -> queued -> applied + | | -> rejected + | | -> expired + | | -> cancelled + +------> cancelled + +------> expired + +Terminal states never return to queued. A draft is immutable. Only its creator +may activate or cancel it. A draft expires after 24 hours. To change one, +cancel it and create another. Repeating activation for the same command returns +its current state and does not allocate another revision. A queued command +expires 30 seconds after activation, using the database clock. A rejected or +expired command requires a new draft. + +Applied records a committed active setpoint. Twin-core then installs that same +revision in memory before stepping the model. A status read can see the database +commit just before memory installation; it exposes both revisions separately. +The next completed model tick may occur later. +It does not mean a physical device moved. + +## HTTP contract + +The API uses its normal response envelope. The internal service uses its +existing token. The public and internal OpenAPI files describe these routes. + +| Route | Actor | Result | +| --- | --- | --- | +| GET /twin/wellheads/{id}/controls | Signed-in reader | Active values, revision, model status, pending command ID | +| POST /twin/control-commands | Current OPERATIONS user | 201 draft; requires Idempotency-Key, well ID, expected revision, reason, and one or more targets | +| POST /twin/control-commands/{id}/activate | Current OPERATIONS user | 202 queued; explicit confirm true and expected revision | +| POST /twin/control-commands/{id}/cancel | Current OPERATIONS user | 200 cancelled if still draft or queued | +| GET /twin/control-commands/{id} | Current OPERATIONS user | Durable status, timestamps, actor, and safe reason code | +| POST /control-commands/{id}/notify | API service only | 202 wake-up accepted; no command payload | + +The internal wake-up never serves as proof that a command applied. The public +create and activate routes have a strict per-user limit of ten requests per +minute, plus one queued command per well. A 409 means a stale revision or state +conflict. A 503 means the database or model cannot accept work. Missing +authentication returns 401, wrong authority 403, bad data 400, and an unknown +well or command 404. A cancelled or expired command is visible through GET. + +The API locks the user row and checks current database role, status, deleted +flag, and session version in each write transaction. The role check uses the +existing capability matrix, rather than a second role list. A concurrent role +change must finish before or after that transaction. The existing JWT role +guard stays as an early check, but cannot be the final authority for +actuation. A newly promoted operator may need to sign in again before the +early guard allows a draft. A downgraded operator cannot activate with an old +token. + +Accepted draft, activation, and cancellation writes put audit_event in the +same transaction as the command change. A failed audit insert rolls back the +change. Rejected requests produce a safe security log and a best-effort audit +row where possible. No raw token or secret enters either record. + +## Tick, restart, and failure logic + +The inbox worker reads at most 100 queued rows per scan and wakes once a second +or on an HTTP hint. It keeps at most one prepared command per well. A backlog +above 100 is exposed through the control backlog metric; it is never silently dropped. The +model tick performs no queue scan. It applies at most one prepared command +across the whole fleet per tick, giving older queued commands a fair turn. + +Lifecycle rows and transactional audit events record drafts and activation. +Worker metrics count application outcomes, expiry, and reconciliation by reason. +They also show queue depth, the backlog beyond one scan, inbox readiness, and +apply duration. +Logs carry the command UUID, well ID, and request ID where available, but no +token or raw reason text. + +The apply transaction uses row locks, a 75 ms lock timeout, and a 250 ms +statement timeout. Opening a connection has a separate two-second timeout. +These are individual operation limits, not a guaranteed 250 ms total deadline. The application checks expiry +using the database clock. A slow call may still delay a tick; the scheduler +must count that gap rather than pretend it ran on time. +It locks the activating user's row before the command and active control rows. +It rechecks the current account status, deleted flag, role, and session version. +Activation stores the verified role and session version with the command. A +change to either, or an inactive account, rejects the command with +`authority_revoked`. Python compares that recorded authority with the current +row; the API's shared capability matrix remains the authority for granting it. +Logout therefore cancels authority for a queued command too. Revocation that +commits after the apply transaction cannot undo an already applied setpoint. + +The transaction checks queued status, expiry, expected revision, active model +version, and parameter fingerprint again. A timeout leaves the old controls active and the +command queued until the next try or expiry. The command, active row, and event +commit together. Only then does the registry change its in-memory controls. +If an unexpected error prevents that memory change, the well becomes +unavailable and logs the command ID; restart reloads the active row. If the +commit outcome is unknown after a lost database reply, the well also becomes +unavailable until twin-core re-reads the active row and command status. It +does not guess whether the old or new controls are active. + +On startup, twin-core loads active controls before the first tick. For a new +well with no control row, it creates revision zero from a validated snapshot or +the documented defaults. An existing active row always wins over controls in +the snapshot. If they differ, recovery keeps the saved physical values and moves their time +to the recovery instant. It records the missing seconds and a control recovery +event. It does not settle the gap using obsolete inputs. The next ordinary +one-second step uses the durable controls. A model version or parameter change updates +the active row's model identity before accepting new drafts, without changing +its control revision. Startup expires unlocked queued commands before releasing +new wells. A concurrently locked row is checked again during application and +by the next inbox scan. An expired command never applies after an outage. + +| Failure | Result visible to the caller | +| --- | --- | +| Twin-core offline after activation | Queued until recovery, then applied or expired; never falsely marked applied | +| Database unavailable before activation commits | 503; no command or audit row committed | +| Activation commit reply lost | Outcome unknown; retry with the same idempotency key or read status before creating anything new | +| Database unavailable before apply commits | Old controls remain active; command stays queued until retry or expiry | +| Apply commit reply lost | Well held unavailable until active revision is read back; no guessed control state | +| Duplicate activation or HTTP hint | Same command ID and revision; no second application | +| Changed model or parameters | Command rejected with a safe reason; old controls remain | +| Logout, role change, or disabled account before application | Rejected with authority_revoked; old controls remain | +| Invalid or out-of-range target | 400 at the API; twin-core also rejects it before apply | +| Crash after active-row commit | Restart loads that active row; skipped process time stays visible | + +## Parameter overrides use the same write trust boundary + +Milestone 8B also retires hand-entered SQL for per-well parameter changes. +ADMIN owns this path; OPERATIONS control authority does not grant it. It uses +a separate model_parameter_change draft table, with key, value, unit, reason, +model version, expected latest override ID, and an idempotency key. Proposals +expire after 24 hours. Only their creator may publish or cancel them. Drafts do not alter the +running model. + +Twin-core provides a read-only validation endpoint using the existing +parameter resolver. Publication validates again, checks the expected override +ID and current ADMIN role, and inserts one append-only +wellhead_model_parameter row with an audit row in one transaction. Publication +returns published, restart required. It never says the new parameter is active +in the running model. The service is told to refresh, and its existing +restart_required state remains authoritative. If that notification fails, the +next 30-second fleet refresh finds the row. The API reports notification +pending rather than claiming an immediate runtime change. + +The parameter proposal has its own status read and cancel route. It does not +enter control_command, use the 30-second control expiry, or change the model +inside a tick. The API shows the published record and the currently running parameter +fingerprint separately until restart completes. A well with an invalid cold +configuration has no running fingerprint; validation can still repair its +parameter set. + +## Build and verification order + +1. Add canonical SQL migration 0012, the Drizzle mirror, and narrow database + grants. Move the API away from the PostgreSQL administrator credential. +2. Add typed command and parameter-change contracts. Keep model limits + authoritative in Python; test the public and internal boundary values + against each other. +3. Add API draft, activation, cancel, read, and transactional audit paths. + Test USER, OPERATIONS, ADMIN, disabled, and downgraded accounts. +4. Add the twin-core inbox, tick application, active-row recovery, and metrics. + Test logout and role revocation while queued, concurrent activation, expiry, duplicate hints, database timeout, + crash after commit, and snapshot reconciliation. +5. Add the parameter draft and publish path, with a real restart test. +6. Update both OpenAPI files and operational documentation. Verify the + complete flow against a disposable Timescale database and the running + Compose stack. Confirm Modbus ingestion still reads model output and that + no Modbus write function is called. + +The release gate is stronger than a green unit suite. A queued command must +never be reported as applied; a failed audit write must prevent activation; +and a restart must recover the last active controls without replaying an +expired command. A real plant deployment remains outside this project's +claim and would require device-specific verification and safety review. + +## Implementation map + +- data/sql/schema.sql owns the tables, immutable + proposal checks, lifecycle transitions, and narrow row-lock helpers. +- data/src/schema.py provisions separate twin and API logins. The API can + write proposals and audit rows but cannot update the active control table. + Twin-core cannot edit user accounts or delete command history. +- monorepo/packages/dto/req/controls.ts owns the public request shapes. + Scenarios and live controls share the allowed field schema; live shutdown + commands add the separate-reset rule. +- monorepo/apps/api/src/control-service.ts owns account checks, locking, + idempotency, and transactions. twin-control-client.ts owns internal HTTP and + response validation. routers/controls.ts owns public HTTP and rate limits. +- twin_core/control_store.py owns database transactions. controls.py owns the + bounded inbox. state.py installs confirmed controls under the registry lock. + runtime.py connects recovery, validation, refresh, and the next model step. + +## Operating the API + +For an existing local stack, add API_DB_USER and a separate URL-safe +API_DB_PASSWORD to .env, then run docker compose up -d --build. The migrator +keeps schema ownership and provisions both application logins. Preserve the +existing volume; this is an additive migration. + +An administrator creates an OPERATIONS account through the existing user API. +That operator signs in and reads GET /api/twin/wellheads/1/controls. Use the +returned active revision when creating a draft: + +```json +{ + "wellheadId": 1, + "expectedRevision": 0, + "reason": "Demonstrate a lower synthetic choke opening", + "targets": {"chokePositionPercent": 50} +} +``` + +Send it to POST /api/twin/control-commands with a new UUID in Idempotency-Key. +Review the saved draft, then POST {"confirm":true,"expectedRevision":0} to +/api/twin/control-commands/{commandId}/activate. HTTP 202 means queued. Read +/api/twin/control-commands/{commandId} until the status is terminal. Keep the +same command ID after a lost reply; do not create another proposal just to retry. + +For parameter changes, ADMIN first reads GET /api/twin/wellheads/1/parameters +to find the current model version and latest override ID for the chosen key. +ADMIN then uses POST /api/twin/parameter-changes with its own +Idempotency-Key. Supply wellheadId, modelVersion, parameterKey, value, unit, +expectedOverrideId (null when none exists), and reason. Publish using +POST /api/twin/parameter-changes/{changeId}/publish with {"confirm":true}. +A null value and null unit together remove the override. The status read +shows the published row ID alongside the running model's parameter fingerprint. +Restart twin-core after reviewing the publication. The restart restores active +controls and loads the newly validated parameter set. + +The API uses same-origin session cookies. Scripted callers must preserve the +authentication cookie and provide the Origin header required by the API's +existing request checks. Neither the browser nor a human caller receives +TWIN_INTERNAL_TOKEN. + +## Verification on 2026-10-13 + +The complete Python suite passed 237 tests before the final expiry and repair +cases were added; its optional projection database test was skipped then. +That projection test subsequently passed against the scoped twin role on a +real disposable Timescale database. The final control database and HTTP group +passed all 24 tests, including the two added cases. +The final control, runtime, persistence, and state regression group passed +another 38 tests after the startup-expiry changes. + +The full API suite passed 89 tests while using the restricted API database role. +The control route suite was also rerun after the internal client was separated. +All 11 control route tests passed. The final API image built and became healthy. +The DTO and existing dashboard suites passed 28 tests. Ruff, strict mypy, +TypeScript checks, ESLint, OpenAPI validation, and prose checks form the static +gates alongside these behavior tests. + +An initial container build found an undeclared direct Zod import in the API; +the route now imports its shared DTO schema. TypeScript also caught untyped +JSON responses in the new tests; those now use explicit response types. Both +gates passed after correction. A Docker image lookup briefly failed with a +TLS timeout and succeeded on retry. The optional projection database check +was run separately, as described above; it is not counted as a skipped release +gate. + +A separate Compose stack first ran the committed Milestone 8 build from a fresh +volume. It was then upgraded with migration 0012 and the working 8B code. The +migrator exited successfully and every long-running service became healthy. +The end-to-end check confirmed: + +- An OPERATIONS draft returns 201, activation returns 202 queued, and the + durable result becomes applied with exactly one new revision. +- Setting choke to 50 percent reaches the historian through Modbus. Model + flow falls and tubing pressure rises. +- A private scenario leaves the active controls and revision unchanged. +- Restart restores the saved control revision and target. +- ADMIN publication marks the running well unavailable pending restart. The + restart adopts the new parameter fingerprint and preserves live controls. +- The four existing dashboard pages render with no browser errors on both the + original and upgraded stacks. + +Failure tests cover authority revocation after queueing, duplicate activation, +invalid targets, failed audit or event writes, expiry, changed model identity, +and a real committed transaction whose reply is deliberately lost. The latter +checks that recovery reads the committed controls instead of guessing. + +The checks above used an isolated deployment before upgrading the original +local stack. They establish the synthetic command workflow, not field +calibration or a physical control safety rating. diff --git a/docs/design/twin-core-milestone-9.md b/docs/design/twin-core-milestone-9.md new file mode 100644 index 0000000..7bce8fe --- /dev/null +++ b/docs/design/twin-core-milestone-9.md @@ -0,0 +1,292 @@ +# Milestone 9: A Wellhead Workspace For Monitoring And Analysis + +Date: 2026-10-16 +Status: Design completed, implemented, and verified locally; usability delivery committed as `afaca24`. + +## Purpose + +A reviewer should be able to find a well, see whether its data is current, run a +forecast, and compare a proposed control change with the unchanged baseline. +Milestones 4 through 8B supply the model, historian, saved projections, and control +contracts. This milestone makes those capabilities understandable in the browser. + +## Design completion gate + +The owner reaffirmed this requirement on 2026-10-16: complete the relevant +visual design before implementing a new screen or a substantial UI change. +Apply the same sequence to later dashboard work. + +Before implementation: + +- Complete desktop and mobile mockups for every affected view. The original + Milestone 9 design set includes Fleet, Monitoring, Forecast, Scenarios, and + Saved runs. +- Show loading, empty, stale, error, and permission states for each view where + they apply. Record why a state does not apply instead of silently omitting it. +- Define shared spacing, typography, colours, and component states with specific + values. Record chart rules for units, times, missing values, baseline lines, + scenario lines, and threshold information. +- Define responsive behaviour: when panels stack, how navigation changes, what + scrolls, and how labels and chart axes remain readable on small screens. +- Render and inspect the mockups on desktop and mobile. Check browser errors + and horizontal overflow, and record the results and remaining design gaps. +- Plan keyboard operation, focus order, accessible names, contrast, status + announcements, and chart alternatives before writing the UI code. + +During implementation, validate accessibility in the working application, +including keyboard and screen-reader use, contrast, zoom and reflow, form +errors, and dynamic status announcements. Record each check and any limitations. +A successful render and a keyboard-focus check do not establish full +accessibility coverage. + +### Completion of the design set + +The first implementation followed only the scenario concept. The owner then +required the missing design work to be completed before further UI changes. +That gap is now addressed by the [five-screen mockup gallery](milestone-9/mockups/index.html) +and [visual specification](milestone-9/visual-specification.md). The gallery covers +ready, loading, empty, stale, error, and permission states, plus validation in +both analysis forms. Its 96 screen/state/viewport combinations rendered without +page errors or horizontal overflow at 1440, 390, and 320 pixels. + +The complete mockup set and specification were written and rendered before the +subsequent application changes. Visual inspection found small phone chart +labels, which were enlarged before implementation proceeded. The application +and gallery now import one shared token stylesheet. Working screenshots remain +separate from illustrative mockups. + +Implementation adds named collapsed navigation, a skip link, navigation and +result focus, field-specific error descriptions, keyboard focus for schedule +editing, historian chart tables, and reduced-motion behaviour. Failed required +refreshes retain readings and drafts within the mounted view and mark them +unverified. A different asset or an authentication failure cannot reuse that +retained state. Saved history retains its prior list after a failed manual refresh. + + +## Screen design + +Keep the existing light theme, blue actions, and purple analysis lines. Use a +muted page background, white panels, consistent spacing, readable labels, and +aligned numeric columns. Alarm and failure colours accompany words and icons. + +The fleet overview uses compact rows instead of repeated large cards. Include +all configured assets, even if they have no readings. Show pressure, production, +freshness, model status, and abnormal-reading counts. Filtering must consider +all available signals so a condition outside the headline measurements remains +visible. Active alarms keep their existing severity order. + +Each wellhead has a common identity header and navigation: + +| View | Layout and behaviour | +| --- | --- | +| Monitoring | Data and model status first, grouped measurements, active alarms, then existing historian charts. Installed controls remain a read-only view. | +| Forecast | Bounded horizon and sample interval beside a large projection chart. Show model start time, confidence reasons, and sampled threshold exceedances. | +| Scenarios | Schedule editor beside a baseline comparison. Mark scheduled changes on the chart. Reopened results show their saved inputs. | +| Saved runs | Per-well forecast or scenario history, name search within the loaded page, bounded pagination, and direct links to immutable results. | + +At narrow widths, panels stack, navigation remains accessible, and wide data +tables scroll inside their own region. Charts keep readable axes rather than +shrinking desktop labels. The same tokens apply to loading, empty, failed, stale, +and permission states. A failed optional model read must not blank historian data. + +The local visual study uses illustrative data. Shipped screens use API data. +There is no new global simulation notice. Equipment drawings need a separate +verified layout; the current scope uses readings, control states, and timelines. + +## Architecture and alternatives + +Use React Router loaders for reads and actions/fetchers for explicit submissions. +Use the existing typed Hono client and shared Zod request schemas. React Router +already owns navigation and refresh, so a second server-data cache would add a +second source of state. Separate pages for every model function would simplify +individual forms but lose well context. The shared workspace preserves it. + +The browser calls nginx, then the authenticated API. Only the API contacts +internal twin-core. Internal credentials never enter browser responses. + +Use existing prediction_run, scenario_run, and their point tables. No schema +change or numbered SQL file is needed. The public API adds bounded history, +canonical signal metadata, and lifecycle status. Saved responses expose the +stored baseline controls and scenario schedule. Projection creation keeps its +existing bounded model runner and audit records. + +Saved runs are readable by users with dashboard:read, following the existing +saved-result policy. Model-copy isolation does not mean owner-only visibility. +Live command entry and parameter publication remain separate future UI work; +this milestone shows installed controls without adding command buttons. + +## Time, charts, and calculations + +Keep three times distinct: last scheduler tick, last successful process time, +and historian sample time. A scheduler tick can advance while a process is +stopped. Status must show the process timestamp and lifecycle reason. Compute +ages on the server; browser refresh delays must not make an old screen appear +current. A failed refresh labels retained data as unverified. +The browser adds elapsed time to the last server-reported telemetry age. It +marks the model check overdue after two missed refresh intervals. The polling +timer reads the current refresh state and skips a request already in flight. + +The existing model integration remains one second. The requested sample interval +only selects output points. Use each point's returned timestamp on the chart. +Do not reuse the historian's fixed two-minute gap algorithm for projections. +Missing values stay missing. Boolean signals use step lines and state labels. +One selected signal and the unit defined in its database catalogue occupy a +chart at a time. + +For a scenario, the displayed difference is: + + difference = scenario_value - baseline_value + +Both values come from the same projection point and parameter code. The unit is +that signal's canonicalUnit. Do not compare values from different timestamps. + +A schedule marker occurs at baseSimulatedAt plus offsetMinutes times 60 seconds. +The model applies it before the following one-second integration step. Chart +markers describe the input schedule, not interpolated threshold crossing times. + +Threshold summaries report the first returned sample with warning or critical +risk. They do not promise that no event occurred between samples. Display the +returned heuristic confidence label and reasons. Do not draw statistical bands +or calculate an alarm probability from that score. + +## Forms and state + +Forecasts accept 300 to 7200 seconds with samples every 5 to 300 seconds. Horizon +must divide evenly by the sample interval. Scenario schedules contain 1 to 16 +ordered steps at whole-minute offsets before the horizon. The shared request +schemas own these rules; Python remains authoritative for model execution. + +Keep drafts while polling. Do not submit on a timer, navigation, or field edit. +Disable repeat submission while pending. A timeout or lost response can occur +after a run was saved, so show an unknown-outcome message and a saved-runs link. +No automatic POST retry is allowed. History is a discovery aid, not an +idempotency guarantee. Reopening a run never executes it again. + +Use explicit loading, no-data, invalid-input, forbidden, expired-session, +not-found, expired-result (410), busy (429/503), and timeout (504) states. +Creation errors keep the draft. A saved result remains visible while another +request is pending, with a clear indication of which inputs produced it. + +## Accessibility + +Controls have visible labels, errors reference their fields, keyboard focus is +visible, and status messages use appropriate live regions without stealing +focus. Baseline and scenario have text legends and different line patterns. +Provide a table containing the plotted values. Respect reduced-motion settings. +These are implementation requirements, not a claim of external certification. + +References: [React Router fetchers](https://reactrouter.com/7.18.4/how-to/fetchers), +[W3C use of colour](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html), +[W3C complex charts](https://www.w3.org/WAI/tutorials/images/complex/), and +[W3C status messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html). + +## Verification gates + +- API tests use the real disposable Timescale database for history, saved inputs, + pagination, units, permission checks, and expired results. +- Model status tests distinguish running ticks from stale process output and + handle partial startup and unreachable services. +- Frontend tests cover schedule serialization, missing values, timestamps, + baseline alignment, and boolean signal display. +- Browser checks cover all views at desktop and mobile sizes, real forecast and + scenario execution, saved reloads, unchanged live controls, validation, + retained drafts, dependency failure, session expiry, and result expiry. +- Run types, lint, formatting, relevant tests, OpenAPI validation, and prose checks. +- Record actual results after implementation. Screenshots must come from the + running UI and must not contain illustrative prototype data. + +The browser test paces requests below the existing API limit. It does not +raise that limit or bypass authentication. Real model runs use the seeded demo +account. Fault cases intercept individual browser responses to exercise recovery +without stopping the shared local services. + +## Costs and accepted limits + +The frontend adds forms and result state that need browser coverage. Polling +uses bounded reads and pauses in hidden tabs. Saved run metadata can outlive its +retained points, so history can contain an expired result. Canonical signal units +remain stable for the supported model versions; changing units later requires +versioned interpretation. Field accuracy still depends on measured calibration +data, as recorded in the model design. + +## Verification on 2026-10-16 + +- 95 API integration tests passed against the disposable Timescale database. + Coverage includes saved inputs, canonical units, bounded history, pagination, + authorization, expired points, partial model startup, unavailable model status, + and configured wells without readings. +- 34 shared-contract and frontend unit tests passed. The six new presentation + checks cover schedule conversion, validation, missing values, zero values, + boolean states, and projection samples at 5, 60, and 300 seconds. +- 14 twin-core HTTP tests passed, including protected status reads and the case + where scheduler ticks continue while the successful process state is stale. +- The complete Chromium browser workflow passed in about 2.4 minutes. It created + and reopened real forecasts and scenarios, checked their original inputs, and + confirmed unchanged installed controls and revision. It checked keyboard focus + and the fleet, Monitoring, Scenarios, and Saved runs layouts at 390 pixels. +- Browser fault checks passed for unavailable model status, an expired result, + a result linked under the wrong well, expired sessions, forbidden requests, + request limits, busy services, and timeouts. Drafts survived errors. Timeout + links selected the matching forecast or scenario history. Saved reloads did + not repeat POST requests. +- During a 35-second intercepted status delay, telemetry became stale and the + model check became overdue. Only one status request remained in flight. +- Workspace types, lint, and formatting passed. Python Ruff checks, formatting, + and mypy passed. Both OpenAPI documents, prose checks, and git diff checks passed. +- The changed API, dashboard, and twin-core images built and were deployed locally. + All six long-running Compose services reported healthy. Eight screenshots were + captured from the working application and linked from the root README. + +Verification caught an API dependency import outside its owning package, mobile +grid overflow, and a polling callback that retained an old refresh state. These +were corrected. The delayed-response test's cleanup race was also fixed. An +unpaced browser run reached the real API request limit; pacing the test resolved +that failure without changing the limit. The final browser run passed without +uncaught page errors. + +The browser workflow is wired into CI, but remote CI has not run. A fresh-volume +start of this complete revision remains a Milestone 10 delivery gate. Database +conversion and physical restore tests were not repeated because this milestone +changes no schema or engine. The owner's physical host sleep/wake deferral remains. + + +## Design completion and accessibility verification on 2026-10-16 + +The remaining design work and the resulting implementation are complete for +this milestone's scope. The [detailed verification record](milestone-9/accessibility-verification.md) +records the browser, assistive technology, checks, and limitations. + +- All three browser workflows passed in 4.4 minutes. These cover the 96 mockup + combinations, accessibility and recovery checks, and the complete model demo. +- Axe reported no violations for the selected WCAG A/AA rules in the tested + views. Keyboard focus, linked form errors, 320px reflow, doubled root text + sizing, reduced motion, retained drafts and history, and permission states passed. +- Orca 46.1 generated the expected field, error, result-heading and table-region + announcements in the keyboard scenario workflow. Speech synthesis initialized; + audio went to a null device. This was a speech-log check, not a listening test. +- All 37 shared-contract and frontend tests passed. All six workspace type tasks + and six lint tasks passed, followed by the final affected website checks. +- The production dashboard image built and started successfully. All six local + Docker services reported healthy. Eight application screenshots were refreshed. +- Prose and diff checks passed. No database schema change was needed. + +The initial screen-reader setup needed additional temporary dependencies and a +speech-server readiness check. The final workflow completed. Its optional mouse +review and audio-cue features were unavailable. NVDA, VoiceOver, braille, other +browser engines, audible quality, remote CI and a fresh-volume start were not +verified in this pass. These limits do not change the recorded functional results. + +## Usability and local delivery verification on 2026-10-17 + +The [usability design](dashboard-usability.md) records the page changes and two +design checks before implementation. The dashboard now includes optional help, +a first-run Guide, clearer draft and result labels, consistent recovery states, +accessible account forms and navigation that works on narrow screens. + +All five browser workflows passed. The 37 shared-contract/frontend tests, +95 API integration tests, 14 model HTTP tests, workspace types and lint passed. +The clean candidate export built all service images and started all six running +services with fresh storage. Schema setup exited 0. Its browser smoke check saved +and reopened a scenario without changing installed controls. This closes the +local fresh-volume gate recorded above. Remote CI, publication and contributor +consent remain open. Physical sleep/wake remains deferred by the owner. diff --git a/docs/openapi/README.md b/docs/openapi/README.md index 279380b..1d09dc8 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -1,13 +1,19 @@ # OpenAPI Contracts -This directory contains committed API specifications for the wellhead monitoring simulation and its planned lightweight digital-twin evolution. +This directory describes the implemented public API and internal model service. ## Specifications - `public-api.yaml`: dashboard-facing TypeScript/Hono API implemented by `monorepo/apps/api`. + Includes model status, saved analyses and their original inputs, historical + scenario sources, scheduled forecasts, live synthetic controls and parameter + publication. It also documents alarm acknowledgement, timed shelves and + action receipt lookup, plus the unauthenticated service metrics endpoint. - `twin-core-internal-api.yaml`: implemented internal reads and operational - endpoints for the Python twin-core service. Control, forecast, and scenario - operations remain planned. + endpoints, protected model status, forecasts, scenarios, installed controls, command wake-up hints, + and parameter validation for the Python twin-core service. Live targets are + stored through the public command lifecycle; internal HTTP never applies + a control payload directly. ## Rules @@ -21,9 +27,12 @@ This directory contains committed API specifications for the wellhead monitoring Run the local contract hygiene check from the monorepo: ```sh -npx --yes pnpm@10.11.1 validate:openapi +pnpm validate:openapi ``` -This checks that each committed spec parses, declares OpenAPI 3.x metadata, has paths, uses unique operation IDs, declares success responses, and resolves internal `$ref` values. +Install the Python dependencies in `requirements-dev.txt` first. The command +checks both specifications for OpenAPI metadata, paths, unique operation IDs, +success responses and valid internal references. -The next production-readiness step is to run this in CI and optionally add a stricter OpenAPI linter. +CI runs this structural check. Endpoint tests verify behavior separately; the +structural check alone cannot prove a running response matches every schema. diff --git a/docs/openapi/public-api.yaml b/docs/openapi/public-api.yaml index d62d2fa..7507a69 100644 --- a/docs/openapi/public-api.yaml +++ b/docs/openapi/public-api.yaml @@ -5,9 +5,10 @@ info: summary: Dashboard-facing API for the wellhead monitoring simulation. description: | This contract documents the public API exposed by the TypeScript/Hono service. - It covers the currently implemented dashboard/auth surface and keeps the - digital-twin evolution honest: twin-core endpoints belong in the internal - contract until they are implemented. + It covers monitoring, authentication, saved projections, synthetic control + commands, parameter proposals and alarm operator actions. Controls and alarm + actions require current OPERATIONS + authority; parameter publication requires ADMIN. No route controls field hardware. servers: - url: /api description: Dashboard reverse-proxy path. @@ -17,7 +18,383 @@ tags: - name: Health - name: Auth - name: Dashboard + - name: Twin + - name: Alarms paths: + /alarms: + get: + tags: + - Alarms + operationId: listAlarmLifecycle + summary: Read retained events and independent condition, acknowledgement and shelf + states. + security: + - cookieAuth: [] + responses: + '200': + description: Current alarm state or the committed action receipt. + content: + application/json: + schema: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/AlarmList' + msg: + type: string + status: + type: integer + const: 200 + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + parameters: + - name: view + in: query + schema: + type: string + enum: + - attention + - active + - history + default: attention + - name: wellheadId + in: query + schema: + type: integer + minimum: 1 + maximum: 2147483647 + - name: cursor + in: query + schema: + type: string + minLength: 1 + maxLength: 600 + /alarms/shelves: + get: + tags: + - Alarms + operationId: listAlarmShelves + summary: Read current shelves, including those with no active event. + security: + - cookieAuth: [] + responses: + '200': + description: Current alarm state or the committed action receipt. + content: + application/json: + schema: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/AlarmShelfList' + msg: + type: string + status: + type: integer + const: 200 + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + parameters: + - name: wellheadId + in: query + schema: + type: integer + minimum: 1 + maximum: 2147483647 + - name: cursor + in: query + schema: + type: string + minLength: 1 + maxLength: 600 + /alarms/events/{eventId}: + get: + tags: + - Alarms + operationId: readAlarmEvent + summary: Read an exact retained event and the latest 20 actions for its well and + rule. + security: + - cookieAuth: [] + responses: + '200': + description: Current alarm state or the committed action receipt. + content: + application/json: + schema: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/AlarmDetail' + msg: + type: string + status: + type: integer + const: 200 + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + '404': + $ref: '#/components/responses/NotFound' + parameters: + - name: eventId + in: path + required: true + schema: + type: string + pattern: ^[1-9][0-9]{0,18}$ + description: Positive PostgreSQL bigint decimal string, at most 9223372036854775807. + Never convert to a JavaScript number. + - name: triggeredAt + in: query + required: true + schema: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + /alarms/actions/{requestKey}: + get: + tags: + - Alarms + operationId: readOwnAlarmReceipt + summary: Find the signed-in actor’s committed result after a missing reply. + security: + - cookieAuth: [] + responses: + '200': + description: Current alarm state or the committed action receipt. + content: + application/json: + schema: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/AlarmReceipt' + msg: + type: string + status: + type: integer + const: 200 + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + '404': + $ref: '#/components/responses/NotFound' + parameters: + - name: requestKey + in: path + required: true + schema: + type: string + format: uuid + /alarms/actions: + post: + tags: + - Alarms + operationId: applyAlarmAction + summary: OPERATIONS acknowledges one event, shelves a rule, or ends a shelf. + security: + - cookieAuth: [] + responses: + '200': + description: Current alarm state or the committed action receipt. + content: + application/json: + schema: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/AlarmReceipt' + msg: + type: string + status: + type: integer + const: 200 + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + '404': + description: Event is no longer retained, or target does not exist. + '409': + description: Acknowledgement, shelf revision or request key conflicts. Read + and review the current state. + '413': + description: Body exceeds 4096 bytes. + '429': + description: Write budget exhausted. + '503': + description: Outcome unknown. Check the request key before retrying. + description: Requires current OPERATIONS authority, explicit confirmation and + a reason. The shared control write budget permits ten writes per user per minute. + Maximum body size is 4096 bytes. Receipt, lifecycle state and audit commit together. + Repeating the same key and input returns the original receipt. A shelf expires + by database time and never changes condition truth. On an uncertain outcome, + look up the same request key before retrying the exact input. + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AlarmActionInput' + /twin/wellheads/{wellheadId}/forecast-schedule: + parameters: + - name: wellheadId + in: path + required: true + schema: {type: integer, minimum: 1, maximum: 2147483647} + get: + tags: [Twin] + operationId: readForecastSchedule + summary: Read the schedule, latest attempt and latest completed job. + security: [{cookieAuth: []}] + responses: + "200": + description: Schedule metadata, with null for records that do not exist. + content: + application/json: + schema: + type: object + required: [data, msg, status] + properties: + msg: {type: string} + status: {type: integer, const: 200} + data: + type: object + required: [schedule, latestJob, latestCompleted] + properties: + schedule: {oneOf: [{$ref: "#/components/schemas/ForecastSchedule"}, {type: "null"}]} + latestJob: {oneOf: [{$ref: "#/components/schemas/ForecastJob"}, {type: "null"}]} + latestCompleted: {oneOf: [{$ref: "#/components/schemas/ForecastJob"}, {type: "null"}]} + "400": {$ref: "#/components/responses/ValidationError"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "500": {$ref: "#/components/responses/ServerError"} + put: + tags: [Twin] + operationId: saveForecastSchedule + summary: Save reviewed settings with current administrator authority. + description: Saving takes ownership and starts a new interval. Disabling leaves claimed work intact. Read status after an uncertain response; do not replay automatically. + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [expectedRevision, enabled, intervalMinutes, durationMinutes, reason, confirmed] + properties: + expectedRevision: {type: integer, minimum: 0, maximum: 2147483646} + enabled: {type: boolean} + intervalMinutes: {type: integer, minimum: 5, maximum: 1440} + durationMinutes: {type: integer, minimum: 5, maximum: 30} + reason: {type: string, minLength: 1, maxLength: 500} + confirmed: {type: boolean, const: true} + responses: + "200": + description: Settings and audit record committed together. + content: + application/json: + schema: + type: object + required: [data, msg, status] + properties: + msg: {type: string} + status: {type: integer, const: 200} + data: {$ref: "#/components/schemas/ForecastSchedule"} + "400": {$ref: "#/components/responses/ValidationError"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "409": + description: Settings changed or the well is inactive. Reload and review before saving. + "503": {$ref: "#/components/responses/ServerError"} + /twin/wellheads/{wellheadId}/snapshots: + get: + summary: Find exact saved states for historical scenarios. + description: Requires scenario analysis permission. Search covers 24 hours at or before a UTC time within the preceding 89 days. Compatibility is checked again when running. + operationId: findTwinSnapshots + security: [{cookieAuth: []}] + parameters: + - name: wellheadId + in: path + required: true + schema: {type: integer, minimum: 1, maximum: 2147483647} + - name: at + in: query + required: true + schema: {$ref: '#/components/schemas/SnapshotSelection/properties/simulatedAt'} + responses: + '200': + description: Bounded snapshot metadata, newest first. + content: + application/json: + schema: + type: object + required: [data, msg, status] + properties: + data: {$ref: '#/components/schemas/SnapshotList'} + msg: {type: string} + status: {type: integer, const: 200} + '400': {description: Invalid search fields or time.} + '403': {description: Missing authority.} + '404': {description: Unknown active well.} + '503': {description: Model parameters or snapshot storage unavailable.} /health: get: tags: [Health] @@ -50,6 +427,27 @@ paths: application/json: schema: $ref: "#/components/schemas/ReadinessResponse" + /metrics: + get: + tags: [Health] + summary: Read API service metrics in Prometheus format. + description: | + Unauthenticated service counters, durations and database-readiness metrics. + This endpoint exposes no well readings. Prometheus scrapes the internal + API address; the dashboard proxy also exposes it at /api/metrics. + Global API rate limiting applies. Reading metrics does not probe the + database again; readiness metrics describe the most recent /ready check. + operationId: getApiMetrics + security: [] + responses: + '200': + description: Current metrics from this API process. + content: + text/plain: + schema: + type: string + '429': + description: API request limit exceeded. /auth/register: post: tags: [Auth] @@ -132,9 +530,13 @@ paths: /auth/logout: post: tags: [Auth] - summary: Clear the current session cookie. + summary: End every session of the signed-in user and clear the cookie. + description: >- + Moves the user's session version on (ADR 0037), so every session token + issued so far is refused from then on, on any device. Requires a session. operationId: logoutUser - security: [] + security: + - cookieAuth: [] responses: "200": description: Logout succeeded. The session cookie is cleared. @@ -143,148 +545,2237 @@ paths: description: Expired `auth_token` cookie. schema: type: string - content: - application/json: - schema: - $ref: "#/components/schemas/LogoutResponse" - "500": - $ref: "#/components/responses/ServerError" - /dashboard/overview: - get: - tags: [Dashboard] - summary: Return dashboard summary, recent readings, and active alarms. - operationId: getDashboardOverview - security: - - cookieAuth: [] - responses: - "200": - description: Recent readings from the historian view. - content: - application/json: - schema: - $ref: "#/components/schemas/DashboardOverviewResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "500": - $ref: "#/components/responses/ServerError" - /dashboard/latest-readings: - get: - tags: [Dashboard] - summary: Return recent wellhead parameter readings. - operationId: getLatestReadings - security: - - cookieAuth: [] - responses: - "200": - description: Latest historian readings. - content: - application/json: - schema: - $ref: "#/components/schemas/LatestReadingsResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "500": - $ref: "#/components/responses/ServerError" - /dashboard/active-alarms: - get: - tags: [Dashboard] - summary: Return active alarm events. - operationId: getActiveAlarms - security: - - cookieAuth: [] - responses: - "200": - description: Active alarm events. - content: - application/json: - schema: - $ref: "#/components/schemas/ActiveAlarmsResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "500": - $ref: "#/components/responses/ServerError" - /dashboard/wellheads/{wellheadId}: - get: - tags: [Dashboard] - summary: Return one wellhead with current readings, active alarms, and per-well trends. - operationId: getWellheadDetail - security: - - cookieAuth: [] - parameters: - - name: wellheadId - in: path - required: true - schema: - type: integer - minimum: 1 - responses: - "200": - description: Wellhead detail data. - content: - application/json: - schema: - $ref: "#/components/schemas/WellheadDetailResponse" - "400": - $ref: "#/components/responses/ValidationError" - "401": - $ref: "#/components/responses/Unauthorized" - "404": - $ref: "#/components/responses/ServerError" - "500": - $ref: "#/components/responses/ServerError" - /dashboard/analytics: - get: - tags: [Dashboard] - summary: Return aggregate trend data for analytics charts. - operationId: getDashboardAnalytics - security: - - cookieAuth: [] - responses: - "200": - description: Hourly and daily aggregate trend data. - content: - application/json: - schema: - $ref: "#/components/schemas/DashboardAnalyticsResponse" - "401": - $ref: "#/components/responses/Unauthorized" - "500": - $ref: "#/components/responses/ServerError" -components: - securitySchemes: - cookieAuth: - type: apiKey - in: cookie - name: auth_token - responses: - Unauthorized: - description: Missing, invalid, or expired session token. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - Forbidden: - description: | - The session is valid but the role lacks the capability this operation - requires. See ADR 0033 for the permission matrix. The response does not - state which roles would be permitted. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - ValidationError: - description: Request validation failed. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - ServerError: - description: Unexpected server-side error. Secrets and raw stack traces are not returned. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - schemas: + content: + application/json: + schema: + $ref: "#/components/schemas/LogoutResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/ServerError" + /dashboard/overview: + get: + tags: [Dashboard] + summary: Return dashboard summary, recent readings, and active alarms. + operationId: getDashboardOverview + security: + - cookieAuth: [] + responses: + "200": + description: Recent readings from the historian view. + content: + application/json: + schema: + $ref: "#/components/schemas/DashboardOverviewResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/ServerError" + /dashboard/latest-readings: + get: + tags: [Dashboard] + summary: Return recent wellhead parameter readings. + operationId: getLatestReadings + security: + - cookieAuth: [] + responses: + "200": + description: Latest historian readings. + content: + application/json: + schema: + $ref: "#/components/schemas/LatestReadingsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/ServerError" + /dashboard/active-alarms: + get: + tags: [Dashboard] + summary: Return totals over all active alarms and a capped, ranked list of them. + operationId: getActiveAlarms + security: + - cookieAuth: [] + responses: + "200": + description: Active alarm events. + content: + application/json: + schema: + $ref: "#/components/schemas/ActiveAlarmsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/ServerError" + /dashboard/wellheads/{wellheadId}: + get: + tags: [Dashboard] + summary: Return one wellhead with current readings, active alarms, and per-well trends. + operationId: getWellheadDetail + security: + - cookieAuth: [] + parameters: + - name: wellheadId + in: path + required: true + schema: + type: integer + minimum: 1 + responses: + "200": + description: Wellhead detail data. + content: + application/json: + schema: + $ref: "#/components/schemas/WellheadDetailResponse" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/ServerError" + "500": + $ref: "#/components/responses/ServerError" + /dashboard/analytics: + get: + tags: [Dashboard] + summary: Return aggregate trend data for analytics charts. + operationId: getDashboardAnalytics + security: + - cookieAuth: [] + responses: + "200": + description: Hourly and daily aggregate trend data. + content: + application/json: + schema: + $ref: "#/components/schemas/DashboardAnalyticsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/ServerError" + /twin/status: + get: + tags: [Twin] + summary: Read lifecycle and successful process freshness, including partial startup. + operationId: getTwinModelStatus + security: [{cookieAuth: []}] + responses: + "200": + description: Model state, or available false if its optional read failed. + content: + application/json: + schema: + type: object + required: [data, msg, status] + properties: + msg: {type: string} + status: {type: integer, const: 200} + data: {$ref: "#/components/schemas/FleetModelStatus"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + /twin/wellheads/{wellheadId}/runs: + get: + tags: [Twin] + summary: List saved run summaries for one well, newest first. + description: Shared with authorized dashboard readers. Metadata can outlive retained points. + operationId: listTwinRuns + security: [{cookieAuth: []}] + parameters: + - name: wellheadId + in: path + required: true + schema: {type: integer, minimum: 1} + - name: kind + in: query + schema: {type: string, enum: [forecast, scenario], default: scenario} + - name: limit + in: query + schema: {type: integer, minimum: 1, maximum: 50, default: 20} + - name: offset + in: query + schema: {type: integer, minimum: 0, maximum: 10000, default: 0} + responses: + "200": + description: Bounded summary page with hasMore for pagination. + content: + application/json: + schema: + type: object + required: [data, msg, status] + properties: + msg: {type: string} + status: {type: integer, const: 200} + data: + type: object + required: [items, hasMore] + properties: + hasMore: {type: boolean} + items: + type: array + maxItems: 50 + items: {$ref: "#/components/schemas/RunSummary"} + "400": {$ref: "#/components/responses/ValidationError"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + /twin/wellheads/{wellheadId}/forecast: + post: + tags: [Twin] + summary: Run and save a forecast from current synthetic model state. + operationId: createTwinForecast + security: [{cookieAuth: []}] + parameters: + - name: wellheadId + in: path + required: true + schema: {type: integer, minimum: 1} + requestBody: + required: true + content: + application/json: + schema: {$ref: "#/components/schemas/ForecastRequest"} + responses: + "200": + description: Forecast saved and returned in the standard API envelope. + content: + application/json: + schema: {$ref: "#/components/schemas/ProjectionEnvelope"} + "400": {$ref: "#/components/responses/ValidationError"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "503": {$ref: "#/components/responses/ServerError"} + "504": {$ref: "#/components/responses/ServerError"} + /twin/scenarios: + post: + tags: [Twin] + summary: Run and save a private control scenario with an unchanged baseline. + operationId: createTwinScenario + security: [{cookieAuth: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: "#/components/schemas/ScenarioRequest"} + responses: + "200": + description: Scenario saved and returned in the standard API envelope. + content: + application/json: + schema: {$ref: "#/components/schemas/ProjectionEnvelope"} + "400": {$ref: "#/components/responses/ValidationError"} + "401": {$ref: "#/components/responses/Unauthorized"} + "403": {$ref: "#/components/responses/Forbidden"} + "404": {$ref: "#/components/responses/NotFound"} + "409": {description: Selected snapshot is incompatible or outside the supported age window.} + "503": {$ref: "#/components/responses/ServerError"} + "504": {$ref: "#/components/responses/ServerError"} + /twin/forecasts/{runId}: + get: + tags: [Twin] + summary: Load a complete saved forecast. + operationId: getTwinForecast + security: [{cookieAuth: []}] + parameters: + - name: runId + in: path + required: true + schema: {type: string, format: uuid} + responses: + "200": + description: Complete forecast with sampled points. + content: + application/json: + schema: {$ref: "#/components/schemas/SavedProjectionEnvelope"} + "400": {$ref: "#/components/responses/ValidationError"} + "404": {$ref: "#/components/responses/NotFound"} + "410": {$ref: "#/components/responses/Gone"} + "401": {$ref: "#/components/responses/Unauthorized"} + /twin/scenarios/{runId}: + get: + tags: [Twin] + summary: Load a complete saved scenario and baseline. + operationId: getTwinScenario + security: [{cookieAuth: []}] + parameters: + - name: runId + in: path + required: true + schema: {type: string, format: uuid} + responses: + "200": + description: Complete scenario with sampled points. + content: + application/json: + schema: {$ref: "#/components/schemas/SavedProjectionEnvelope"} + "400": {$ref: "#/components/responses/ValidationError"} + "404": {$ref: "#/components/responses/NotFound"} + "410": {$ref: "#/components/responses/Gone"} + "401": {$ref: "#/components/responses/Unauthorized"} + /twin/wellheads/{wellheadId}/controls: + get: + tags: + - Twin + summary: Read durable and running synthetic controls. + operationId: getLiveControlState + security: + - cookieAuth: [] + responses: + '200': + description: Durable status. A queued command is not yet applied. + content: + application/json: + schema: + $ref: '#/components/schemas/ControlStateResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown command or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + parameters: + - name: wellheadId + in: path + required: true + schema: + type: integer + minimum: 1 + maximum: 2147483647 + /twin/wellheads/{wellheadId}/control-commands: + get: + tags: + - Twin + summary: Read the current OPERATIONS user's proposals for one well. + operationId: listOwnControlProposals + security: + - cookieAuth: [] + parameters: + - name: wellheadId + in: path + required: true + schema: + type: integer + minimum: 1 + maximum: 2147483647 + - name: offset + in: query + schema: + type: integer + minimum: 0 + maximum: 10000 + default: 0 + - name: idempotencyKey + in: query + description: Optional exact lookup for the current creator after a missing response. + schema: + type: string + format: uuid + responses: + '200': + description: At most 20 own proposals, newest first. Does not require the + model. + content: + application/json: + schema: + type: object + required: + - data + - msg + - status + properties: + msg: + type: string + status: + const: 200 + data: + type: object + required: + - items + - offset + - hasMore + properties: + items: + type: array + maxItems: 20 + items: + $ref: '#/components/schemas/ControlProposalSummary' + offset: + type: integer + hasMore: + type: boolean + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + description: Database unavailable. + /twin/wellheads/{wellheadId}/parameter-changes: + get: + tags: + - Twin + summary: Read the current ADMIN user's proposals for one well. + operationId: listOwnParameterProposals + security: + - cookieAuth: [] + parameters: + - name: wellheadId + in: path + required: true + schema: + type: integer + minimum: 1 + maximum: 2147483647 + - name: offset + in: query + schema: + type: integer + minimum: 0 + maximum: 10000 + default: 0 + - name: idempotencyKey + in: query + description: Optional exact lookup for the current creator after a missing response. + schema: + type: string + format: uuid + responses: + '200': + description: At most 20 own proposals, newest first. Does not require the + model. + content: + application/json: + schema: + type: object + required: + - data + - msg + - status + properties: + msg: + type: string + status: + const: 200 + data: + type: object + required: + - items + - offset + - hasMore + properties: + items: + type: array + maxItems: 20 + items: + $ref: '#/components/schemas/ParameterChange' + offset: + type: integer + hasMore: + type: boolean + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '503': + description: Database unavailable. + /twin/control-commands: + post: + tags: + - Twin + summary: Create an immutable OPERATIONS control draft. + operationId: draftControlCommand + security: + - cookieAuth: [] + responses: + '201': + description: Durable status. A queued command is not yet applied. + content: + application/json: + schema: + $ref: '#/components/schemas/ControlCommandResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown command or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlDraftRequest' + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + format: uuid + /twin/control-commands/{id}/activate: + post: + tags: + - Twin + summary: Explicitly activate the creator's command. + operationId: activateControlCommand + security: + - cookieAuth: [] + responses: + '202': + description: Durable status. A queued command is not yet applied. + content: + application/json: + schema: + $ref: '#/components/schemas/ControlCommandResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown command or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + '200': + description: Repeated activation returns an existing terminal result without + requeuing. + content: + application/json: + schema: + $ref: '#/components/schemas/ControlCommandResponse' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlActivateRequest' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + /twin/control-commands/{id}/cancel: + post: + tags: + - Twin + summary: Cancel the creator's draft or queued command. + operationId: cancelControlCommand + security: + - cookieAuth: [] + responses: + '200': + description: Durable status. A queued command is not yet applied. + content: + application/json: + schema: + $ref: '#/components/schemas/ControlCommandResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown command or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExplicitConfirmation' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + /twin/control-commands/{id}: + get: + tags: + - Twin + summary: Read command status and lifecycle events. + operationId: getControlCommand + security: + - cookieAuth: [] + responses: + '200': + description: Durable status. A queued command is not yet applied. + content: + application/json: + schema: + $ref: '#/components/schemas/ControlCommandResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown command or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + /twin/parameter-changes: + post: + tags: + - Twin + summary: Create an ADMIN parameter proposal. + operationId: draftParameterChange + security: + - cookieAuth: [] + responses: + '201': + description: Stored proposal status. A new draft needs explicit publication. + content: + application/json: + schema: + $ref: '#/components/schemas/ParameterChangeResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown parameter proposal or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ParameterDraftRequest' + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + format: uuid + /twin/parameter-changes/{id}/publish: + post: + tags: + - Twin + summary: Publish an attributed override requiring restart. + operationId: publishParameterChange + security: + - cookieAuth: [] + responses: + '200': + description: Stored proposal status. A published override requires model restart; it is not proof of running adoption. + content: + application/json: + schema: + $ref: '#/components/schemas/ParameterChangeResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown parameter proposal or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExplicitConfirmation' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + /twin/parameter-changes/{id}/cancel: + post: + tags: + - Twin + summary: Cancel the creator's parameter proposal. + operationId: cancelParameterChange + security: + - cookieAuth: [] + responses: + '200': + description: Stored proposal status. Cancels a draft; an already terminal proposal keeps its outcome. + content: + application/json: + schema: + $ref: '#/components/schemas/ParameterChangeResponse' + '400': + description: Invalid JSON, target, or confirmation. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown parameter proposal or well. + '409': + description: Stale revision, conflicting proposal, or idempotency-key reuse. + '429': + description: More than ten writes per authenticated user per minute. + '503': + description: Dependency unavailable or commit outcome unknown. Read status + or retry the same proposal; do not assume a write failed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExplicitConfirmation' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + /twin/parameter-changes/{id}: + get: + tags: + - Twin + summary: Read the proposal and the currently running parameter identity. + operationId: getParameterChange + security: + - cookieAuth: [] + responses: + '200': + description: Stored proposal status and running parameter identity, or null when the model read is unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/ParameterChangeResponse' + '400': + description: Invalid proposal identifier. + '401': + description: Missing or revoked session. + '403': + description: Current account lacks authority. + '404': + description: Unknown parameter proposal or well. + '429': + description: API request limit exceeded. + '503': + description: Proposal storage unavailable. Retry the status read. + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + /twin/wellheads/{wellheadId}/parameters: + get: + tags: [Twin] + summary: Read current override revision IDs and running model identity. + operationId: getModelParameterStatus + security: [{cookieAuth: []}] + parameters: + - name: wellheadId + in: path + required: true + schema: {type: integer, minimum: 1} + responses: + '200': + description: ADMIN read with latestOverrides, modelVersion, status, and running controls (or null if unavailable). + content: + application/json: + schema: + type: object + required: [data, msg, status] + properties: + msg: {type: string} + status: {const: 200} + data: + type: object + required: [wellheadId, modelVersion, status, latestOverrides, running, catalogue] + properties: + wellheadId: {type: integer} + modelVersion: {type: string} + status: {type: string} + running: + anyOf: + - {$ref: '#/components/schemas/RunningControls'} + - {type: 'null'} + catalogue: {$ref: '#/components/schemas/ParameterCatalogue'} + latestOverrides: + type: array + items: + type: object + required: [parameterOverrideId, wellheadId, modelVersion, parameterKey, value, unit, source, changedBy, reason, validFrom] + properties: + parameterOverrideId: {type: integer} + wellheadId: {type: integer} + modelVersion: {type: string} + parameterKey: {type: string} + value: {type: [number, 'null']} + unit: {type: [string, 'null']} + source: {type: string} + changedBy: {type: string} + reason: {type: string} + validFrom: {type: string, format: date-time} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '503': {description: Model identity or database unavailable.} +components: + securitySchemes: + cookieAuth: + type: apiKey + in: cookie + name: auth_token + responses: + Unauthorized: + description: Missing, invalid, or expired session token. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: | + The session is valid but the role lacks the capability this operation + requires. See ADR 0033 for the permission matrix. The response does not + state which roles would be permitted. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ValidationError: + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: The requested well or saved run does not exist. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Gone: + description: Some or all saved points have expired. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ServerError: + description: Unexpected server-side error. Secrets and raw stack traces are not returned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + schemas: + AlarmTarget: + type: object + required: + - wellheadId + - wellheadName + - locationName + - ruleId + - parameterName + - unit + - operator + - threshold + - severity + - triggerAfter + - clearAfter + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + wellheadName: + type: string + locationName: + type: string + ruleId: + type: integer + minimum: 1 + maximum: 2147483647 + parameterName: + type: string + unit: + oneOf: + - type: string + - type: 'null' + operator: + type: string + threshold: + type: number + severity: + type: string + triggerAfter: + type: integer + minimum: 1 + maximum: 2147483647 + clearAfter: + type: integer + minimum: 1 + maximum: 2147483647 + AlarmShelfState: + type: object + required: + - revision + - until + - active + - updatedAt + - actorName + - reason + properties: + revision: + type: integer + minimum: 1 + maximum: 2147483647 + until: + oneOf: + - type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + - type: 'null' + active: + type: boolean + updatedAt: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + actorName: + type: string + reason: + type: string + AlarmReceipt: + type: object + required: + - actionId + - action + - occurredAt + - actorUserId + - actorName + - reason + - target + - eventId + - triggeredAt + - revision + - shelvedUntil + properties: + actionId: + type: string + format: uuid + action: + type: string + enum: + - acknowledge + - shelve + - unshelve + occurredAt: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + actorUserId: + type: integer + minimum: 1 + maximum: 2147483647 + actorName: + type: string + reason: + type: string + target: + $ref: '#/components/schemas/AlarmTarget' + eventId: + oneOf: + - type: string + pattern: ^[1-9][0-9]{0,18}$ + description: Positive PostgreSQL bigint decimal string, at most 9223372036854775807. + Never convert to a JavaScript number. + - type: 'null' + triggeredAt: + oneOf: + - type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + - type: 'null' + revision: + oneOf: + - type: integer + minimum: 1 + maximum: 2147483647 + - type: 'null' + shelvedUntil: + oneOf: + - type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + - type: 'null' + AlarmLifecycleEvent: + type: object + required: + - eventId + - triggeredAt + - clearedAt + - severity + - triggeredValue + - target + - acknowledgement + - shelf + properties: + eventId: + type: string + pattern: ^[1-9][0-9]{0,18}$ + description: Positive PostgreSQL bigint decimal string, at most 9223372036854775807. + Never convert to a JavaScript number. + triggeredAt: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + clearedAt: + oneOf: + - type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + - type: 'null' + severity: + type: string + triggeredValue: + type: number + target: + $ref: '#/components/schemas/AlarmTarget' + acknowledgement: + oneOf: + - type: object + required: + - at + - actorName + - reason + properties: + at: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event + identity. + actorName: + type: string + reason: + type: string + - type: 'null' + shelf: + oneOf: + - $ref: '#/components/schemas/AlarmShelfState' + - type: 'null' + AlarmSummary: + type: object + required: + - active + - critical + - attention + - shelves + - retained + properties: + active: + type: integer + minimum: 0 + critical: + type: integer + minimum: 0 + attention: + type: integer + minimum: 0 + shelves: + type: integer + minimum: 0 + retained: + type: integer + minimum: 0 + AlarmList: + type: object + required: + - wells + - summary + - observedAt + - nextCursor + - items + properties: + wells: + type: array + items: + type: object + required: + - wellheadId + - wellheadName + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + wellheadName: + type: string + summary: + $ref: '#/components/schemas/AlarmSummary' + observedAt: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + nextCursor: + oneOf: + - type: string + - type: 'null' + items: + type: array + items: + $ref: '#/components/schemas/AlarmLifecycleEvent' + maxItems: 50 + AlarmShelfList: + type: object + required: + - wells + - summary + - observedAt + - nextCursor + - items + - total + properties: + wells: + type: array + items: + type: object + required: + - wellheadId + - wellheadName + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + wellheadName: + type: string + summary: + $ref: '#/components/schemas/AlarmSummary' + observedAt: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + nextCursor: + oneOf: + - type: string + - type: 'null' + items: + type: array + items: + type: object + required: + - target + - state + properties: + target: + $ref: '#/components/schemas/AlarmTarget' + state: + $ref: '#/components/schemas/AlarmShelfState' + maxItems: 50 + total: + type: integer + minimum: 0 + AlarmDetail: + type: object + required: + - observedAt + - event + - actions + properties: + observedAt: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + event: + $ref: '#/components/schemas/AlarmLifecycleEvent' + actions: + type: array + items: + $ref: '#/components/schemas/AlarmReceipt' + maxItems: 20 + AlarmActionInput: + oneOf: + - type: object + required: + - action + - eventId + - triggeredAt + - reason + - confirmed + properties: + action: + type: string + const: acknowledge + eventId: + type: string + pattern: ^[1-9][0-9]{0,18}$ + description: Positive PostgreSQL bigint decimal string, at most 9223372036854775807. + Never convert to a JavaScript number. + triggeredAt: + type: string + format: date-time + description: UTC timestamp; preserve all six fractional digits for event identity. + reason: + type: string + minLength: 1 + maxLength: 500 + description: Trimmed operator reason. + confirmed: + type: boolean + const: true + additionalProperties: false + - type: object + required: + - action + - wellheadId + - ruleId + - expectedRevision + - durationMinutes + - reason + - confirmed + properties: + action: + type: string + const: shelve + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + ruleId: + type: integer + minimum: 1 + maximum: 2147483647 + expectedRevision: + type: integer + minimum: 0 + maximum: 2147483646 + durationMinutes: + type: integer + minimum: 5 + maximum: 480 + reason: + type: string + minLength: 1 + maxLength: 500 + description: Trimmed operator reason. + confirmed: + type: boolean + const: true + additionalProperties: false + - type: object + required: + - action + - wellheadId + - ruleId + - expectedRevision + - reason + - confirmed + properties: + action: + type: string + const: unshelve + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + ruleId: + type: integer + minimum: 1 + maximum: 2147483647 + expectedRevision: + type: integer + minimum: 0 + maximum: 2147483646 + reason: + type: string + minLength: 1 + maxLength: 500 + description: Trimmed operator reason. + confirmed: + type: boolean + const: true + additionalProperties: false + LiveControlTargets: + type: object + required: [] + properties: + chokePositionPercent: + type: number + minimum: 0 + maximum: 100 + masterValveOpen: + type: boolean + wingValveOpen: + type: boolean + pumpOn: + type: boolean + emergencyShutdown: + type: boolean + additionalProperties: false + minProperties: 1 + description: Absolute targets. Missing fields keep their value; null is invalid. + Shutdown and reset each require a command containing only emergencyShutdown. While + shutdown is active, other changes are refused. + RunningControls: + type: object + required: + - wellheadId + - revision + - modelVersion + - parameterFingerprint + - status + - lastTickAt + - controls + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + revision: + type: integer + minimum: 0 + maximum: 2147483646 + modelVersion: + type: string + parameterFingerprint: + type: string + minLength: 64 + maxLength: 64 + status: + type: string + lastTickAt: + type: + - string + - 'null' + format: date-time + controls: + type: object + required: + - chokePositionPercent + - masterValveOpen + - wingValveOpen + - pumpOn + - emergencyShutdown + - swabValveOpen + properties: + chokePositionPercent: + type: number + minimum: 0 + maximum: 100 + masterValveOpen: + type: boolean + wingValveOpen: + type: boolean + pumpOn: + type: boolean + emergencyShutdown: + type: boolean + swabValveOpen: + type: boolean + additionalProperties: false + additionalProperties: false + ControlDraftRequest: + type: object + required: + - wellheadId + - expectedRevision + - reason + - targets + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + expectedRevision: + type: integer + minimum: 0 + maximum: 2147483646 + reason: + type: string + minLength: 1 + maxLength: 500 + targets: + $ref: '#/components/schemas/LiveControlTargets' + additionalProperties: false + ControlActivateRequest: + type: object + required: + - confirm + - expectedRevision + properties: + confirm: + const: true + expectedRevision: + type: integer + minimum: 0 + maximum: 2147483646 + additionalProperties: false + ExplicitConfirmation: + type: object + required: + - confirm + properties: + confirm: + const: true + additionalProperties: false + ControlCommand: + type: object + required: + - commandId + - wellheadId + - createdBy + - idempotencyKey + - reason + - expectedRevision + - modelVersion + - parameterFingerprint + - chokePositionPercent + - masterValveOpen + - wingValveOpen + - pumpOn + - emergencyShutdown + - status + - createdAt + - expiresAt + - queuedAt + - activatedBy + - activatedRole + - activatedSessionVersion + - resultAt + - resultRevision + - resultReason + - requestId + properties: + commandId: + type: string + format: uuid + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + createdBy: + type: integer + minimum: 1 + maximum: 2147483647 + idempotencyKey: + type: string + format: uuid + reason: + type: string + maxLength: 500 + expectedRevision: + type: integer + minimum: 0 + maximum: 2147483646 + modelVersion: + type: string + parameterFingerprint: + type: string + chokePositionPercent: + type: + - number + - 'null' + minimum: 0 + maximum: 100 + masterValveOpen: + type: + - boolean + - 'null' + wingValveOpen: + type: + - boolean + - 'null' + pumpOn: + type: + - boolean + - 'null' + emergencyShutdown: + type: + - boolean + - 'null' + status: + type: string + enum: + - draft + - queued + - applied + - rejected + - expired + - cancelled + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + queuedAt: + type: + - string + - 'null' + format: date-time + activatedBy: + type: + - integer + - 'null' + activatedRole: + type: + - string + - 'null' + activatedSessionVersion: + type: + - integer + - 'null' + resultAt: + type: + - string + - 'null' + format: date-time + resultRevision: + type: + - integer + - 'null' + resultReason: + type: + - string + - 'null' + requestId: + type: + - string + - 'null' + events: + type: array + items: + type: object + required: + - eventId + - commandId + - eventType + - occurredAt + - actorUserId + - reasonCode + - requestId + properties: + eventId: + type: integer + minimum: 1 + maximum: 2147483647 + commandId: + type: string + format: uuid + eventType: + type: string + occurredAt: + type: string + format: date-time + actorUserId: + type: + - integer + - 'null' + reasonCode: + type: string + requestId: + type: + - string + - 'null' + notificationPending: + type: boolean + ParameterCatalogue: + type: object + required: + - wellheadId + - modelVersion + - parameters + properties: + wellheadId: + type: integer + minimum: 1 + modelVersion: + type: string + parameters: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: + - key + - description + - unit + - defaultValue + - minimum + - maximum + - requiresRestart + - runningValue + properties: + key: + type: string + description: + type: string + unit: + type: string + defaultValue: + type: number + minimum: + type: number + maximum: + type: number + requiresRestart: + type: boolean + runningValue: + type: + - number + - 'null' + ControlProposalSummary: + type: object + required: + - commandId + - wellheadId + - createdBy + - idempotencyKey + - reason + - expectedRevision + - modelVersion + - chokePositionPercent + - masterValveOpen + - wingValveOpen + - pumpOn + - emergencyShutdown + - status + - createdAt + - expiresAt + - resultAt + - resultRevision + - resultReason + properties: + commandId: + type: string + format: uuid + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + createdBy: + type: integer + minimum: 1 + maximum: 2147483647 + idempotencyKey: + type: string + format: uuid + reason: + type: string + maxLength: 500 + expectedRevision: + type: integer + minimum: 0 + maximum: 2147483646 + modelVersion: + type: string + chokePositionPercent: + type: + - number + - 'null' + minimum: 0 + maximum: 100 + masterValveOpen: + type: + - boolean + - 'null' + wingValveOpen: + type: + - boolean + - 'null' + pumpOn: + type: + - boolean + - 'null' + emergencyShutdown: + type: + - boolean + - 'null' + status: + type: string + enum: + - draft + - queued + - applied + - rejected + - expired + - cancelled + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + resultAt: + type: + - string + - 'null' + format: date-time + resultRevision: + type: + - integer + - 'null' + resultReason: + type: + - string + - 'null' + ParameterDraftRequest: + type: object + required: + - wellheadId + - modelVersion + - parameterKey + - value + - unit + - expectedOverrideId + - reason + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + modelVersion: + type: string + pattern: ^\d+\.\d+\.\d+$ + parameterKey: + type: string + minLength: 1 + maxLength: 100 + value: + type: + - number + - 'null' + unit: + type: + - string + - 'null' + minLength: 1 + maxLength: 40 + expectedOverrideId: + type: + - integer + - 'null' + minimum: 1 + reason: + type: string + minLength: 1 + maxLength: 500 + additionalProperties: false + description: Null value and unit together remove an override. Ranges and units are + validated by twin-core. + ParameterChange: + type: object + required: + - changeId + - wellheadId + - createdBy + - idempotencyKey + - modelVersion + - parameterKey + - value + - unit + - reason + - expectedOverrideId + - status + - createdAt + - expiresAt + - resultAt + - publishedOverrideId + properties: + changeId: + type: string + format: uuid + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + createdBy: + type: integer + minimum: 1 + maximum: 2147483647 + idempotencyKey: + type: string + format: uuid + modelVersion: + type: string + parameterKey: + type: string + value: + type: + - number + - 'null' + unit: + type: + - string + - 'null' + reason: + type: string + expectedOverrideId: + type: + - integer + - 'null' + status: + type: string + enum: + - draft + - published + - cancelled + - expired + createdAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + resultAt: + type: + - string + - 'null' + format: date-time + publishedOverrideId: + type: + - integer + - 'null' + restartRequired: + type: boolean + notificationPending: + type: boolean + running: + anyOf: + - $ref: '#/components/schemas/RunningControls' + - type: 'null' + ControlState: + type: object + required: + - active + - running + - pendingCommandId + properties: + active: + anyOf: + - type: object + required: + - wellheadId + - revision + - modelVersion + - parameterFingerprint + - chokePositionPercent + - masterValveOpen + - wingValveOpen + - pumpOn + - emergencyShutdown + - swabValveOpen + - appliedCommandId + - appliedAt + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + revision: + type: integer + minimum: 0 + maximum: 2147483646 + modelVersion: + type: string + parameterFingerprint: + type: string + chokePositionPercent: + type: number + minimum: 0 + maximum: 100 + masterValveOpen: + type: boolean + wingValveOpen: + type: boolean + pumpOn: + type: boolean + emergencyShutdown: + type: boolean + swabValveOpen: + type: boolean + appliedCommandId: + type: + - string + - 'null' + format: uuid + appliedAt: + type: string + format: date-time + - type: 'null' + running: + $ref: '#/components/schemas/RunningControls' + pendingCommandId: + type: + - string + - 'null' + format: uuid + ControlCommandResponse: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/ControlCommand' + msg: + type: string + status: + type: integer + ParameterChangeResponse: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/ParameterChange' + msg: + type: string + status: + type: integer + ControlStateResponse: + type: object + required: + - data + - msg + - status + properties: + data: + $ref: '#/components/schemas/ControlState' + msg: + type: string + status: + type: integer + ForecastRequest: + type: object + additionalProperties: false + properties: + horizonSeconds: {type: integer, minimum: 300, maximum: 7200, default: 1800} + stepSeconds: {type: integer, minimum: 5, maximum: 300, default: 60} + description: Horizon must be divisible by step size. + SnapshotSelection: + type: object + additionalProperties: false + required: [snapshotId, simulatedAt] + properties: + snapshotId: + type: string + pattern: '^[1-9][0-9]{0,18}$' + description: Exact decimal bigint, at most 9223372036854775807. + simulatedAt: + type: string + format: date-time + description: UTC timestamp with at most six fractional digits. + ExecutionOrigin: + oneOf: + - type: object + required: [kind] + properties: + kind: {type: string, const: manual} + - type: object + required: [kind, scheduleId, jobId, scheduledFor] + properties: + kind: {type: string, const: scheduled} + scheduleId: {type: string, format: uuid} + jobId: {type: string, format: uuid} + scheduledFor: {type: string, format: date-time} + BranchOrigin: + oneOf: + - type: object + additionalProperties: false + required: [kind] + properties: + kind: {type: string, const: current} + - type: object + additionalProperties: false + required: [kind, snapshotId, simulatedAt] + properties: + kind: {type: string, const: snapshot} + snapshotId: {$ref: '#/components/schemas/SnapshotSelection/properties/snapshotId'} + simulatedAt: {$ref: '#/components/schemas/SnapshotSelection/properties/simulatedAt'} + SnapshotList: + type: object + required: [wellheadId, checkedAt, items, hasMore] + properties: + wellheadId: {type: integer, minimum: 1} + checkedAt: {type: string, format: date-time} + hasMore: {type: boolean} + items: + type: array + maxItems: 20 + items: + type: object + required: [snapshotId, simulatedAt, modelVersion, compatible, reason] + properties: + snapshotId: {$ref: '#/components/schemas/SnapshotSelection/properties/snapshotId'} + simulatedAt: {$ref: '#/components/schemas/SnapshotSelection/properties/simulatedAt'} + modelVersion: {type: string} + compatible: {type: boolean} + reason: {type: string, enum: [compatible, model_version_changed, parameters_changed]} + ScenarioRequest: + type: object + additionalProperties: false + required: [wellheadId, name, controls] + properties: + baseSnapshot: {$ref: "#/components/schemas/SnapshotSelection"} + wellheadId: {type: integer, minimum: 1} + name: {type: string, minLength: 1, maxLength: 120} + horizonSeconds: {type: integer, minimum: 300, maximum: 7200, default: 1800} + stepSeconds: {type: integer, minimum: 5, maximum: 300, default: 60} + controls: + type: array + minItems: 1 + maxItems: 16 + items: + type: object + additionalProperties: false + required: [offsetMinutes, input] + properties: + offsetMinutes: {type: integer, minimum: 0} + input: + type: object + additionalProperties: false + minProperties: 1 + properties: + chokePositionPercent: {type: number, minimum: 0, maximum: 100} + masterValveOpen: {type: boolean} + wingValveOpen: {type: boolean} + pumpOn: {type: boolean} + emergencyShutdown: {type: boolean} + description: Control offsets must increase and stay inside the horizon. + FleetModelStatus: + type: object + required: [checkedAt, available, wellheads] + properties: + checkedAt: {type: string, format: date-time} + available: {type: boolean} + wellheads: + type: array + maxItems: 1000 + items: + type: object + required: [wellheadId, modelVersion, status, reason, lastTickAt, + simulatedAt, processAgeSeconds, current, skippedTicks, restoredFrom, + lastRecoveryEvent] + properties: + wellheadId: {type: integer, minimum: 1} + modelVersion: {type: string} + status: {type: string} + reason: {type: string} + lastTickAt: {type: [string, "null"], format: date-time} + simulatedAt: {type: [string, "null"], format: date-time} + processAgeSeconds: {type: [number, "null"]} + current: {type: boolean} + skippedTicks: {type: integer, minimum: 0} + restoredFrom: {type: [string, "null"], format: date-time} + lastRecoveryEvent: + type: [object, "null"] + required: [type, time] + properties: + type: {type: string} + time: {type: string, format: date-time} + RunSummary: + type: object + required: [runId, kind, wellheadId, name, createdAt, baseSimulatedAt, + modelVersion, horizonSeconds, stepSeconds, confidenceScore, branchOrigin, executionOrigin] + properties: + branchOrigin: {$ref: "#/components/schemas/BranchOrigin"} + executionOrigin: {$ref: "#/components/schemas/ExecutionOrigin"} + runId: {type: string, format: uuid} + kind: {type: string, enum: [forecast, scenario]} + wellheadId: {type: integer, minimum: 1} + name: {type: [string, "null"]} + createdAt: {type: string, format: date-time} + baseSimulatedAt: {type: string, format: date-time} + modelVersion: {type: string} + horizonSeconds: {type: integer} + stepSeconds: {type: integer} + confidenceScore: {type: number, minimum: 0, maximum: 1} + SavedProjectionEnvelope: + allOf: + - {$ref: "#/components/schemas/ProjectionEnvelope"} + - type: object + properties: + data: + type: object + required: [baselineControls, scheduledControls, signals] + properties: + baselineControls: + type: object + description: Original model control values, using persisted field names. + additionalProperties: {type: [number, boolean]} + scheduledControls: + type: array + items: {$ref: "#/components/schemas/ScenarioRequest/properties/controls/items"} + signals: + type: array + items: + type: object + required: [code, displayName, canonicalUnit, dataType] + properties: + code: {type: string} + displayName: {type: string} + canonicalUnit: {type: [string, "null"]} + dataType: {type: string} + ForecastSchedule: + type: object + required: [scheduleId, wellheadId, ownerUserId, ownerRole, enabled, intervalSeconds, horizonSeconds, stepSeconds, revision, nextDueAt, reason, updatedAt] + properties: + scheduleId: {type: string, format: uuid} + wellheadId: {type: integer} + ownerUserId: {type: integer} + ownerRole: {type: string, const: ADMIN} + enabled: {type: boolean} + intervalSeconds: {type: integer, minimum: 300, maximum: 86400, multipleOf: 60} + horizonSeconds: {type: integer, minimum: 300, maximum: 1800, multipleOf: 60} + stepSeconds: {type: integer, const: 60} + revision: {type: integer, minimum: 1} + nextDueAt: {type: [string, "null"], format: date-time} + reason: {type: string} + updatedAt: {type: string, format: date-time} + ForecastJob: + type: object + required: [jobId, scheduleId, scheduleRevision, wellheadId, ownerUserId, ownerRole, intervalSeconds, horizonSeconds, stepSeconds, scheduledFor, startedAt, leaseExpiresAt, missedIntervals, status, completedAt, reasonCode, runId] + properties: + jobId: {type: string, format: uuid} + scheduleId: {type: string, format: uuid} + scheduleRevision: {type: integer} + wellheadId: {type: integer} + ownerUserId: {type: integer} + ownerRole: {type: string, const: ADMIN} + intervalSeconds: {type: integer} + horizonSeconds: {type: integer} + stepSeconds: {type: integer, const: 60} + scheduledFor: {type: string, format: date-time} + startedAt: {type: string, format: date-time} + leaseExpiresAt: {type: string, format: date-time} + missedIntervals: {type: integer, minimum: 0} + status: {type: string, enum: [running, complete, skipped, failed]} + completedAt: {type: [string, "null"], format: date-time} + reasonCode: {type: [string, "null"]} + runId: {type: [string, "null"], format: uuid} + ProjectionEnvelope: + type: object + required: [msg, data, status] + properties: + msg: {type: string} + status: {type: integer, const: 200} + data: + type: object + required: [runId, kind, wellheadId, source, fieldCalibrated, modelVersion, + baseSimulatedAt, generatedAt, horizonSeconds, stepSeconds, + confidence, points, branchOrigin, executionOrigin] + properties: + runId: {type: string, format: uuid} + kind: {type: string, enum: [forecast, scenario]} + wellheadId: {type: integer, minimum: 1} + name: {type: [string, "null"]} + source: {type: string, const: synthetic_reduced_order_model} + fieldCalibrated: {type: boolean, const: false} + modelVersion: {type: string} + branchOrigin: {$ref: "#/components/schemas/BranchOrigin"} + executionOrigin: {$ref: "#/components/schemas/ExecutionOrigin"} + baseSimulatedAt: {type: string, format: date-time} + generatedAt: {type: string, format: date-time} + horizonSeconds: {type: integer} + stepSeconds: {type: integer} + confidence: + type: object + required: [method, score, label, reasons] + properties: + method: {type: string, const: heuristic_uncalibrated} + score: {type: number, minimum: 0, maximum: 1} + label: {type: string, enum: [moderate, low]} + reasons: + type: array + items: {type: string} + points: + type: array + minItems: 1 + maxItems: 1440 + items: + type: object + required: [elapsedSeconds, timestamp, values, alarmRisk, + triggeredRuleIds] + properties: + elapsedSeconds: {type: integer, minimum: 1} + timestamp: {type: string, format: date-time} + values: + type: object + additionalProperties: {type: number} + alarmRisk: {type: string, enum: [none, warning, critical, unknown]} + triggeredRuleIds: + type: array + items: {type: integer} + baselineValues: + type: object + additionalProperties: {type: number} + baselineAlarmRisk: + type: string + enum: [none, warning, critical, unknown] HealthResponse: type: object required: [status, service, timestamp] @@ -425,8 +2916,17 @@ components: $ref: "#/components/schemas/DashboardOverviewData" DashboardOverviewData: type: object - required: [summary, latestReadings, activeAlarms] + required: [summary, latestReadings, activeAlarms, wellheads] properties: + wellheads: + type: array + items: + allOf: + - {$ref: "#/components/schemas/WellheadAsset"} + - type: object + required: [activeAlarmCount] + properties: + activeAlarmCount: {type: integer, minimum: 0} summary: $ref: "#/components/schemas/DashboardSummary" latestReadings: @@ -463,12 +2963,35 @@ components: type: integer data: type: object - required: [alarms] + required: [totals, alarms] properties: + totals: + $ref: "#/components/schemas/ActiveAlarmTotals" alarms: type: array + description: >- + At most 50 active alarms, most severe first and then oldest + first. It can hold fewer rows than totals.total. + maxItems: 50 items: $ref: "#/components/schemas/ActiveAlarm" + ActiveAlarmTotals: + type: object + description: Counts over every active alarm, not only the listed ones. + required: [total, critical, warning, affectedWellheads] + properties: + total: + type: integer + minimum: 0 + critical: + type: integer + minimum: 0 + warning: + type: integer + minimum: 0 + affectedWellheads: + type: integer + minimum: 0 DashboardAnalyticsResponse: type: object required: [msg, data, status] @@ -483,7 +3006,7 @@ components: - pressureTrend - temperatureFlowTrend - waterCutGorTrend - - dailyAlarmCounts + - alarmCounts properties: pressureTrend: type: array @@ -497,10 +3020,11 @@ components: type: array items: $ref: "#/components/schemas/AggregateTrendPoint" - dailyAlarmCounts: + alarmCounts: type: array + description: One total per severity over the last 24 hours. items: - $ref: "#/components/schemas/DailyAlarmCount" + $ref: "#/components/schemas/AlarmCount" WellheadDetailResponse: type: object required: [msg, data, status] @@ -513,6 +3037,7 @@ components: type: object required: - wellhead + - latestReadingAgeSeconds - latestReadingAt - latestReadings - activeAlarms @@ -522,6 +3047,7 @@ components: properties: wellhead: $ref: "#/components/schemas/WellheadAsset" + latestReadingAgeSeconds: {type: [number, "null"], minimum: 0} latestReadingAt: type: [string, "null"] format: date-time @@ -573,7 +3099,7 @@ components: type: string DashboardSummary: type: object - required: [totalWellheads, parametersTracked, activeAlarms, latestReadingAt] + required: [totalWellheads, parametersTracked, activeAlarms, latestReadingAt, latestReadingAgeSeconds] properties: totalWellheads: type: integer @@ -581,6 +3107,7 @@ components: type: integer activeAlarms: type: integer + latestReadingAgeSeconds: {type: [number, "null"], minimum: 0} latestReadingAt: type: [string, "null"] format: date-time @@ -636,8 +3163,12 @@ components: type: [string, "null"] fieldName: type: [string, "null"] + parameterCode: + type: [string, "null"] parameterDisplayName: type: [string, "null"] + canonicalUnit: + type: [string, "null"] operator: type: [string, "null"] thresholdValue: @@ -646,7 +3177,7 @@ components: type: [integer, "null"] AggregateTrendPoint: type: object - description: Hourly aggregate trend point from a TimescaleDB aggregate view. + description: Two-minute aggregate trend point from a TimescaleDB continuous aggregate. properties: bucketTime: type: [string, "null"] @@ -665,25 +3196,16 @@ components: type: [number, "null"] readingCount: type: [integer, "null"] - DailyAlarmCount: + AlarmCount: type: object - description: Daily alarm count grouped by wellhead, parameter, and severity. + description: Alarms triggered in the last 24 hours for one severity. + required: [severityLevel, alarmsTriggered] properties: - bucketDay: - type: [string, "null"] - format: date-time - wellheadId: - type: [integer, "null"] - wellheadName: - type: [string, "null"] - locationName: - type: [string, "null"] - parameterDisplayName: - type: [string, "null"] severityLevel: type: [string, "null"] - totalAlarmsTriggered: - type: [integer, "null"] + alarmsTriggered: + type: integer + minimum: 0 ErrorResponse: type: object properties: diff --git a/docs/openapi/twin-core-internal-api.yaml b/docs/openapi/twin-core-internal-api.yaml index b8a8bd8..6073231 100644 --- a/docs/openapi/twin-core-internal-api.yaml +++ b/docs/openapi/twin-core-internal-api.yaml @@ -1,14 +1,13 @@ openapi: 3.1.0 info: title: Twin-Core Internal API - version: 0.4.0 + version: 0.5.0 summary: Internal API for the wellhead twin-core service. description: | Health, readiness, metrics, lifecycle state, complete model fleet snapshots, - and source-labelled historian telemetry reads are implemented. Process - values are synthetic and uncalibrated. - Controls, forecasts, and scenarios below - are planned endpoints, not available in the running service. + source-labelled historian telemetry reads, forecasts, and isolated scenarios + are implemented. Controls enter through durable commands and an authenticated + wake-up hint. Parameter validation is read-only. Values remain synthetic and uncalibrated. servers: - url: http://twin-core:8000 description: Internal Docker Compose service address. @@ -20,6 +19,32 @@ tags: - name: Forecasts - name: Scenarios paths: + /wellheads/{wellheadId}/snapshots: + get: + summary: Find exact saved states for historical scenarios. + description: Requires the internal service token. Search covers 24 hours at or before a UTC time within the preceding 89 days. Compatibility is checked again when running. + operationId: findInternalSnapshots + security: [{serviceToken: []}] + parameters: + - name: wellheadId + in: path + required: true + schema: {type: integer, minimum: 1, maximum: 2147483647} + - name: at + in: query + required: true + schema: {$ref: '#/components/schemas/SnapshotSelection/properties/simulatedAt'} + responses: + '200': + description: Bounded snapshot metadata, newest first. + content: + application/json: + schema: + $ref: '#/components/schemas/SnapshotList' + '400': {description: Invalid search fields or time.} + '403': {description: Missing authority.} + '404': {description: Unknown active well.} + '503': {description: Model parameters or snapshot storage unavailable.} /health: get: tags: [Health] @@ -56,6 +81,19 @@ paths: text/plain: schema: type: string + /model-status: + get: + summary: Read lifecycle and process freshness even during partial startup. + operationId: getModelStatus + security: [{serviceToken: []}] + responses: + "200": + description: A registry snapshot with freshness computed by the model clock. + content: + application/json: + schema: {$ref: "#/components/schemas/FleetModelStatus"} + "403": + description: Internal service token missing or invalid. /model-snapshot: get: tags: [Telemetry] @@ -108,11 +146,12 @@ paths: $ref: "#/components/schemas/WellheadState" "404": $ref: "#/components/responses/NotFound" - /wellheads/{wellheadId}/controls: + /wellheads/{wellheadId}/forecast: post: - tags: [Controls] - summary: Planned endpoint for applying bounded live control inputs. - operationId: applyWellheadControls + tags: [Forecasts] + summary: Create and persist a bounded forecast from current model state. + description: Requires X-Twin-Internal-Token and X-Twin-Actor-Id headers. + operationId: createWellheadForecast parameters: - $ref: "#/components/parameters/WellheadId" requestBody: @@ -120,63 +159,206 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ControlInput" + $ref: "#/components/schemas/ForecastRequest" responses: - "202": - description: Control input accepted for the next model tick. + "201": + description: Forecast persisted and returned. content: application/json: schema: - $ref: "#/components/schemas/ControlAccepted" + $ref: "#/components/schemas/ForecastResponse" "400": $ref: "#/components/responses/ValidationError" "404": $ref: "#/components/responses/NotFound" - /wellheads/{wellheadId}/forecast: + "403": + $ref: "#/components/responses/Forbidden" + "503": + $ref: "#/components/responses/HistorianUnavailable" + /scenarios: post: - tags: [Forecasts] - summary: Planned endpoint for short operational forecasts. - operationId: createWellheadForecast - parameters: - - $ref: "#/components/parameters/WellheadId" + tags: [Scenarios] + summary: Create and persist an isolated what-if scenario. + description: Requires X-Twin-Internal-Token and X-Twin-Actor-Id headers. + operationId: runScenario requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ForecastRequest" + $ref: "#/components/schemas/ScenarioRequest" responses: - "200": - description: Forecast generated from current model state. + "201": + description: Scenario and unchanged baseline persisted together. content: application/json: schema: - $ref: "#/components/schemas/ForecastResponse" + $ref: "#/components/schemas/ScenarioResponse" "400": $ref: "#/components/responses/ValidationError" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" - /scenarios: + "409": {description: Selected snapshot is incompatible or outside the supported age window.} + "503": + $ref: "#/components/responses/HistorianUnavailable" + /wellheads/{wellheadId}/controls: + get: + tags: + - Controls + summary: Read installed model setpoints. + operationId: getInstalledControls + security: + - serviceToken: [] + responses: + '200': + description: Read result or notification acceptance; a hint never applies + controls. + content: + application/json: + schema: + $ref: '#/components/schemas/RunningControls' + '400': + description: Invalid request. + '403': + description: Missing or incorrect service token. + '404': + description: Unknown well. + '503': + description: Model, database, or inbox unavailable. + parameters: + - $ref: '#/components/parameters/WellheadId' + /control-commands/{id}/notify: post: - tags: [Scenarios] - summary: Planned endpoint for isolated what-if scenario execution. - operationId: runScenario + tags: + - Controls + summary: Wake polling for a durable command UUID. + operationId: notifyCommandInbox + security: + - serviceToken: [] + responses: + '202': + description: Read result or notification acceptance; a hint never applies + controls. + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationAccepted' + '400': + description: Invalid request. + '403': + description: Missing or incorrect service token. + '404': + description: Unknown well. + '503': + description: Model, database, or inbox unavailable. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/ScenarioRequest" + $ref: '#/components/schemas/EmptyHint' + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + /wellheads/{wellheadId}/parameters: + get: + tags: + - Controls + summary: Read canonical parameter definitions and running values. + operationId: getParameterCatalogue + security: + - serviceToken: [] + parameters: + - name: wellheadId + in: path + required: true + schema: + type: integer + minimum: 1 responses: - "200": - description: Scenario run completed against cloned model state. + '200': + description: Definitions remain available if the process cannot start; runningValue + is then null. content: application/json: schema: - $ref: "#/components/schemas/ScenarioResponse" - "400": - $ref: "#/components/responses/ValidationError" + $ref: '#/components/schemas/ParameterCatalogue' + '403': + description: Missing or incorrect service token. + '404': + description: Unknown well. + /parameter-changes/validate: + post: + tags: + - Controls + summary: Validate without publishing or changing running parameters. + operationId: validateParameterProposal + security: + - serviceToken: [] + responses: + '200': + description: Read result or notification acceptance; a hint never applies + controls. + content: + application/json: + schema: + $ref: '#/components/schemas/ParameterValidation' + '400': + description: Invalid request. + '403': + description: Missing or incorrect service token. + '404': + description: Unknown well. + '503': + description: Model, database, or inbox unavailable. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ParameterValidationRequest' + /fleet/refresh: + post: + tags: + - Controls + summary: Request a fleet refresh on the scheduler thread. + operationId: requestFleetRefresh + security: + - serviceToken: [] + responses: + '202': + description: Read result or notification acceptance; a hint never applies + controls. + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationAccepted' + '400': + description: Invalid request. + '403': + description: Missing or incorrect service token. + '404': + description: Unknown well. + '503': + description: Model, database, or inbox unavailable. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyHint' components: + securitySchemes: + serviceToken: + type: apiKey + in: header + name: X-Twin-Internal-Token parameters: WellheadId: name: wellheadId @@ -204,7 +386,205 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: The internal service token is absent or invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" schemas: + FleetModelStatus: + type: object + required: [checkedAt, available, wellheads] + properties: + checkedAt: {type: string, format: date-time} + available: {type: boolean} + wellheads: + type: array + maxItems: 1000 + items: + type: object + required: [wellheadId, modelVersion, status, reason, lastTickAt, + simulatedAt, processAgeSeconds, current, skippedTicks, restoredFrom, + lastRecoveryEvent] + properties: + wellheadId: {type: integer, minimum: 1} + modelVersion: {type: string} + status: {type: string} + reason: {type: string} + lastTickAt: {type: [string, "null"], format: date-time} + simulatedAt: {type: [string, "null"], format: date-time} + processAgeSeconds: {type: [number, "null"]} + current: {type: boolean} + skippedTicks: {type: integer, minimum: 0} + restoredFrom: {type: [string, "null"], format: date-time} + lastRecoveryEvent: + type: [object, "null"] + required: [type, time] + properties: + type: {type: string} + time: {type: string, format: date-time} + RunningControls: + type: object + required: + - wellheadId + - revision + - modelVersion + - parameterFingerprint + - status + - lastTickAt + - controls + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + revision: + type: integer + minimum: 0 + maximum: 2147483646 + modelVersion: + type: string + parameterFingerprint: + type: string + minLength: 64 + maxLength: 64 + status: + type: string + lastTickAt: + type: + - string + - 'null' + format: date-time + controls: + type: object + required: + - chokePositionPercent + - masterValveOpen + - wingValveOpen + - pumpOn + - emergencyShutdown + - swabValveOpen + properties: + chokePositionPercent: + type: number + minimum: 0 + maximum: 100 + masterValveOpen: + type: boolean + wingValveOpen: + type: boolean + pumpOn: + type: boolean + emergencyShutdown: + type: boolean + swabValveOpen: + type: boolean + additionalProperties: false + additionalProperties: false + ParameterValidationRequest: + type: object + required: + - wellheadId + - modelVersion + - parameterKey + - value + - unit + properties: + wellheadId: + type: integer + minimum: 1 + maximum: 2147483647 + modelVersion: + type: string + pattern: ^\d+\.\d+\.\d+$ + parameterKey: + type: string + minLength: 1 + maxLength: 100 + value: + type: + - number + - 'null' + unit: + type: + - string + - 'null' + minLength: 1 + maxLength: 40 + additionalProperties: false + ParameterCatalogue: + type: object + required: + - wellheadId + - modelVersion + - parameters + properties: + wellheadId: + type: integer + minimum: 1 + modelVersion: + type: string + parameters: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: + - key + - description + - unit + - defaultValue + - minimum + - maximum + - requiresRestart + - runningValue + properties: + key: + type: string + description: + type: string + unit: + type: string + defaultValue: + type: number + minimum: + type: number + maximum: + type: number + requiresRestart: + type: boolean + runningValue: + type: + - number + - 'null' + ParameterValidation: + type: object + required: + - modelVersion + - runningParameterFingerprint + - proposedParameterFingerprint + - restartRequired + properties: + modelVersion: + type: string + runningParameterFingerprint: + type: [string, "null"] + proposedParameterFingerprint: + type: string + restartRequired: + const: true + EmptyHint: + type: object + additionalProperties: false + NotificationAccepted: + type: object + required: + - status + properties: + status: + const: notification_accepted HealthResponse: type: object required: [status, service, timestamp] @@ -249,6 +629,13 @@ components: minimum: 0 reason: type: string + description: >- + The first thing stopping readiness, or service_ready_model_running. + Values include historian_unavailable, no_active_wellheads, + invalid_model_parameters, wall_clock_moved_back (ticks held after + the wall clock went back), tick_clock_drift (last tick more than + 3 seconds from wall time), tick_loop_not_current, and + model_not_running. timestamp: type: string format: date-time @@ -263,6 +650,8 @@ components: - skippedTicks - lastTickAt - process + - restoredFrom + - lastRecoveryEvent properties: wellheadId: type: integer @@ -289,6 +678,25 @@ components: oneOf: - $ref: "#/components/schemas/ProcessState" - type: "null" + restoredFrom: + description: UTC time of the snapshot restored for this process, if any. + oneOf: + - type: string + format: date-time + - type: "null" + lastRecoveryEvent: + description: Last startup recovery decision for this well in this process. + oneOf: + - type: object + required: [type, time] + properties: + type: + type: string + enum: [snapshot_restored, cold_start_no_snapshot, snapshot_incompatible] + time: + type: string + format: date-time + - type: "null" ProcessState: type: object description: Synthetic model output. It is not historian telemetry or a field measurement. @@ -503,116 +911,228 @@ components: - type: string enum: [synthetic_random_simulator, synthetic_reduced_order_model] - type: "null" - ControlInput: - type: object - additionalProperties: false - properties: - chokePositionPercent: - type: number - minimum: 0 - maximum: 100 - valveState: - type: string - enum: [open, closed] - pumpState: - type: string - enum: [on, off] - reason: - type: string - maxLength: 240 - ControlAccepted: - type: object - required: [accepted, effectiveFromTick] - properties: - accepted: - type: boolean - const: true - effectiveFromTick: - type: string - format: date-time ForecastRequest: type: object - required: [horizonMinutes] + additionalProperties: false properties: - horizonMinutes: + horizonSeconds: type: integer - minimum: 5 - maximum: 240 - timestepSeconds: + minimum: 300 + maximum: 7200 + default: 1800 + stepSeconds: type: integer - minimum: 1 + minimum: 5 maximum: 300 + default: 60 + description: Horizon must be divisible by step size. ForecastResponse: + $ref: "#/components/schemas/ProjectionResponse" + SnapshotSelection: type: object - required: [wellheadId, generatedAt, horizonMinutes, points] + additionalProperties: false + required: [snapshotId, simulatedAt] properties: - wellheadId: - type: integer - generatedAt: + snapshotId: + type: string + pattern: '^[1-9][0-9]{0,18}$' + description: Exact decimal bigint, at most 9223372036854775807. + simulatedAt: type: string format: date-time - horizonMinutes: - type: integer - points: - type: array - items: - $ref: "#/components/schemas/ForecastPoint" - ForecastPoint: + description: UTC timestamp with at most six fractional digits. + ExecutionOrigin: + oneOf: + - type: object + required: [kind] + properties: + kind: {type: string, const: manual} + - type: object + required: [kind, scheduleId, jobId, scheduledFor] + properties: + kind: {type: string, const: scheduled} + scheduleId: {type: string, format: uuid} + jobId: {type: string, format: uuid} + scheduledFor: {type: string, format: date-time} + BranchOrigin: + oneOf: + - type: object + additionalProperties: false + required: [kind] + properties: + kind: {type: string, const: current} + - type: object + additionalProperties: false + required: [kind, snapshotId, simulatedAt] + properties: + kind: {type: string, const: snapshot} + snapshotId: {$ref: '#/components/schemas/SnapshotSelection/properties/snapshotId'} + simulatedAt: {$ref: '#/components/schemas/SnapshotSelection/properties/simulatedAt'} + SnapshotList: type: object - required: [timestamp, pressurePsi, temperatureF, flowRateBpd, confidence] + required: [wellheadId, checkedAt, items, hasMore] properties: - timestamp: - type: string - format: date-time - pressurePsi: - type: number - temperatureF: - type: number - flowRateBpd: - type: number - confidence: - type: number - minimum: 0 - maximum: 1 + wellheadId: {type: integer, minimum: 1} + checkedAt: {type: string, format: date-time} + hasMore: {type: boolean} + items: + type: array + maxItems: 20 + items: + type: object + required: [snapshotId, simulatedAt, modelVersion, compatible, reason] + properties: + snapshotId: {$ref: '#/components/schemas/SnapshotSelection/properties/snapshotId'} + simulatedAt: {$ref: '#/components/schemas/SnapshotSelection/properties/simulatedAt'} + modelVersion: {type: string} + compatible: {type: boolean} + reason: {type: string, enum: [compatible, model_version_changed, parameters_changed]} ScenarioRequest: type: object - required: [wellheadId, name, horizonMinutes, controls] + additionalProperties: false + required: [wellheadId, name, horizonSeconds, stepSeconds, controls] properties: + baseSnapshot: {$ref: "#/components/schemas/SnapshotSelection"} wellheadId: type: integer + minimum: 1 name: type: string + minLength: 1 maxLength: 120 - horizonMinutes: + horizonSeconds: + type: integer + minimum: 300 + maximum: 7200 + stepSeconds: type: integer minimum: 5 - maximum: 240 + maximum: 300 controls: type: array + minItems: 1 + maxItems: 16 items: $ref: "#/components/schemas/ScenarioControlStep" + description: Horizon must divide by step; offsets must increase and stay below horizon. ScenarioControlStep: type: object + additionalProperties: false required: [offsetMinutes, input] properties: offsetMinutes: type: integer minimum: 0 input: - $ref: "#/components/schemas/ControlInput" + $ref: "#/components/schemas/ScenarioControlChange" + ScenarioControlChange: + type: object + additionalProperties: false + minProperties: 1 + properties: + chokePositionPercent: + type: number + minimum: 0 + maximum: 100 + masterValveOpen: + type: boolean + wingValveOpen: + type: boolean + pumpOn: + type: boolean + emergencyShutdown: + type: boolean ScenarioResponse: + $ref: "#/components/schemas/ProjectionResponse" + ProjectionResponse: type: object - required: [scenarioId, generatedAt, points] + required: [runId, kind, wellheadId, name, source, fieldCalibrated, + modelVersion, baseSimulatedAt, generatedAt, horizonSeconds, + stepSeconds, confidence, points, branchOrigin, executionOrigin] properties: - scenarioId: + branchOrigin: {$ref: "#/components/schemas/BranchOrigin"} + executionOrigin: {$ref: "#/components/schemas/ExecutionOrigin"} + runId: type: string + format: uuid + kind: + type: string + enum: [forecast, scenario] + wellheadId: + type: integer + minimum: 1 + name: + type: [string, "null"] + source: + type: string + const: synthetic_reduced_order_model + fieldCalibrated: + type: boolean + const: false + modelVersion: + type: string + baseSimulatedAt: + type: string + format: date-time generatedAt: type: string format: date-time + horizonSeconds: + type: integer + stepSeconds: + type: integer + confidence: + type: object + required: [method, score, label, reasons] + properties: + method: + type: string + const: heuristic_uncalibrated + score: + type: number + minimum: 0 + maximum: 1 + label: + type: string + enum: [moderate, low] + reasons: + type: array + items: + type: string points: type: array + minItems: 1 + maxItems: 1440 items: - $ref: "#/components/schemas/ForecastPoint" + $ref: "#/components/schemas/ProjectionPoint" + ProjectionPoint: + type: object + required: [elapsedSeconds, timestamp, values, alarmRisk, triggeredRuleIds] + properties: + elapsedSeconds: + type: integer + minimum: 1 + timestamp: + type: string + format: date-time + values: + type: object + additionalProperties: + type: number + alarmRisk: + type: string + enum: [none, warning, critical, unknown] + triggeredRuleIds: + type: array + items: + type: integer + baselineValues: + type: object + additionalProperties: + type: number + baselineAlarmRisk: + type: string + enum: [none, warning, critical, unknown] ErrorResponse: type: object required: [error] diff --git a/docs/operations/database-updates.md b/docs/operations/database-updates.md new file mode 100644 index 0000000..facd598 --- /dev/null +++ b/docs/operations/database-updates.md @@ -0,0 +1,168 @@ +# Update an existing database + +Checked against application commit `82c9baf`, 2026-10-17. + +The project maintains one schema, `data/sql/schema.sql`. An existing database +also records which source and generated contract it matches. The contract is a +comparison record of tables, functions and policies. It is not another schema. + +Choose the path below before starting a newer application against existing data. +The installer refuses a mismatch instead of guessing a conversion. + +## Choose the starting point + +| Installed state | Next operation | +| --- | --- | +| Empty database on the current engine | Normal Compose startup installs the complete current schema | +| Legacy numbered history, no `schema_installation` | Adopt with the installer, SQL and contract that match that legacy structure and engine | +| Adopted PostgreSQL 14 / TimescaleDB 2.19.3 baseline | Verify physical recovery, convert a copy, then accept the engine with unchanged schema source | +| PostgreSQL 16.15 / TimescaleDB 2.30.2 baseline missing only the new tables | Run the current `extend` mode with its exact previous SQL and contract | +| Current baseline and engine | Normal startup checks it; `check` performs a read-only verification | +| Unknown history, changed existing definitions or an incomplete installation | Inspect the mismatch and prepare a specific conversion or recovery plan | + +Do not run today's `adopt` against the original 13-file legacy installation. +The current schema includes five later tables that legacy history never created. +Adoption checks structure; it does not add those tables. Likewise, `accept-engine` +requires identical SQL source before and after the engine change. It cannot also +accept new application tables. + +## Prepare and identify the installation + +Keep a verified backup and compare a restored copy before changing the main +installation. Use the [physical recovery procedure](../design/consolidated-schema.md#repeating-the-physical-backup-check) +for the affected old engine. Use the [complete logical restore procedure](../design/database-engine-upgrade.md#restoring-a-logical-backup-completely) +after the engine upgrade, including roles and database grants. Restore serially, +with the TimescaleDB pre/post restore calls; do not use `pg_restore --jobs`. + +Record the engine versions, legacy ledger and, when present, the source and +contract checksums in `schema_installation`. Compare them with the candidate +reference. A commit name alone does not prove the database matches it. An +interrupted installation needs recovery before any of these paths can continue. + +These historical references are available in Git: + +| Reference | Meaning | +| --- | --- | +| `cd09691` | Consolidated legacy schema on PostgreSQL 14 / TimescaleDB 2.19.3 | +| `e150992` | The same schema source on PostgreSQL 16.15 / TimescaleDB 2.30.2 | +| `54576c0` | Adds `forecast_schedule` and `forecast_job` | +| `6631dd4` | Same database schema as `54576c0`, before alarm lifecycle tables | +| `82c9baf` | Adds `alarm_action`, `alarm_acknowledgement` and `alarm_shelf` | + +An older numbered ledger must contain every expected legacy checksum before +adoption. Unknown or earlier partial histories need their own reviewed path. +Do not manufacture ledger rows or edit checksums to make a check pass. + +## Command environment + +Run the examples from the repository root in Bash. Activate a Python 3.12 virtual +environment with `requirements-dev.txt` installed. Install the pnpm dependencies +under `monorepo/` as described in its README. Commands below use its dotenv loader +to read the ignored root `.env`, without printing credentials. + +`POSTGRES_USER`, `POSTGRES_PASSWORD` and `POSTGRES_DB` must identify the database +administrator and database being verified. Service provisioning also uses the +configured `TWIN_DB_*` and `API_DB_*` values. Set the host and port for the +restored copy first: + +```sh +schema_db_host=127.0.0.1 +schema_db_port=55440 +schema_repo_root=$(pwd) + +schema_python() { + ( + cd "$schema_repo_root/monorepo" || exit 1 + pnpm exec dotenv -e ../.env -- env \ + POSTGRES_HOST="$schema_db_host" POSTGRES_PORT="$schema_db_port" \ + python3 "$@" + ) +} +``` + +Port 55440 is the recovery example, not an automatically created server. Confirm +the target before running a command. After the rehearsal, stop application +writers and take the final backup before repeating the verified operation on +the main installation. Its default host database port is 5434. + +## Adopt the original legacy structure + +Export the matching historical files into a temporary directory. This leaves +the current branch untouched and supplies the correct installer and SQL together: + +```sh +schema_legacy_dir=$(mktemp -d /tmp/well-schema-legacy.XXXXXX) +git archive cd09691 | tar -x -C "$schema_legacy_dir" +schema_python "$schema_legacy_dir/data/src/schema.py" adopt --without-seeds +schema_python "$schema_legacy_dir/data/src/schema.py" check +``` + +This example applies only to the complete legacy structure on its original +PostgreSQL 14 / TimescaleDB 2.19.3 engine. It verifies all 13 legacy files and +the actual catalog, then records the baseline without replaying SQL or seeds. +It also provisions configured service grants. A database already adopted at +this baseline needs only its matching `check` before the engine procedure. + +## Convert the engine without adding tables + +Follow the [engine conversion design](../design/database-engine-upgrade.md) on +copies of the verified old cluster. Keep the original volume. That procedure +requires binary conversion, extension update, record and grant comparisons, +logical restore verification and application checks. + +Use the `e150992` installer and contract for engine acceptance, because its SQL +matches `cd09691`. Run this after binary conversion, extension update and the +record, permission and catalog comparisons pass on the copy: + +```sh +schema_engine_dir=$(mktemp -d /tmp/well-schema-engine.XXXXXX) +git archive e150992 | tar -x -C "$schema_engine_dir" +schema_python "$schema_engine_dir/data/src/schema.py" accept-engine \ + --previous-contract "$schema_legacy_dir/data/sql/schema-contract.json" +schema_python "$schema_engine_dir/data/src/schema.py" check +``` + +For an already adopted source, export `cd09691` as above to obtain its contract +without running `adopt` again. Point `schema_db_host` and `schema_db_port` at the +converted server before accepting it. Engine acceptance changes the recorded +contract identity and adds its audit event; it does not install tables or grants. +Do not substitute the current five-table extension's contract at this stage. +Complete the logical restore and application checks before changing the main +installation, following the order in the engine design. + +## Add the current ordinary tables + +Select the commit whose SQL and contract match the installed PostgreSQL 16 +baseline. The example starts immediately after engine acceptance. Use `54576c0` +or `6631dd4` instead if that installation already has scheduled forecasts. + +```sh +schema_installed_commit=e150992 +schema_previous_dir=$(mktemp -d /tmp/well-schema-previous.XXXXXX) +git show "$schema_installed_commit:data/sql/schema.sql" > "$schema_previous_dir/schema.sql" +git show "$schema_installed_commit:data/sql/schema-contract.json" > "$schema_previous_dir/schema-contract.json" +schema_python "$schema_repo_root/data/src/schema.py" extend \ + --previous-schema "$schema_previous_dir/schema.sql" \ + --previous-contract "$schema_previous_dir/schema-contract.json" \ + --without-seeds +schema_python "$schema_repo_root/data/src/schema.py" check +``` + +The target SQL must begin with the exact previous source. The installer accepts +only new ordinary tables and their indexes, verifies the resulting catalog and +records the new identity with an audit event in one transaction. It provisions +service grants afterward. Existing definitions and stored readings remain intact. +The [extension design](../design/schema-extension.md) lists every refusal case. + +Normal startup can proceed after the main installation passes the same checks. +Set `TIMESCALE_DATA_VOLUME` to the verified converted volume when an engine change +required one. Confirm readiness, current readings, stored controls and service +permissions after starting the current application. Keep the backup and reference +files with the deployment record; they are not new maintained schema files. + +## Verification scope + +The consolidation, engine upgrade, scheduled extension and alarm extension each +have dated verification records in their linked designs. This guide brings their +order together. The documentation update checks source identities and command +interfaces; it does not claim a new end-to-end legacy upgrade rehearsal. diff --git a/docs/screenshots/alarm-center.png b/docs/screenshots/alarm-center.png new file mode 100644 index 0000000..20b32a5 Binary files /dev/null and b/docs/screenshots/alarm-center.png differ diff --git a/docs/screenshots/alarm-review-desktop.png b/docs/screenshots/alarm-review-desktop.png new file mode 100644 index 0000000..a0c5748 Binary files /dev/null and b/docs/screenshots/alarm-review-desktop.png differ diff --git a/docs/screenshots/alarm-review-mobile.png b/docs/screenshots/alarm-review-mobile.png new file mode 100644 index 0000000..cc0a421 Binary files /dev/null and b/docs/screenshots/alarm-review-mobile.png differ diff --git a/docs/screenshots/analytics.png b/docs/screenshots/analytics.png new file mode 100644 index 0000000..261d549 Binary files /dev/null and b/docs/screenshots/analytics.png differ diff --git a/docs/screenshots/controls-mobile.png b/docs/screenshots/controls-mobile.png new file mode 100644 index 0000000..75bfcae Binary files /dev/null and b/docs/screenshots/controls-mobile.png differ diff --git a/docs/screenshots/controls.png b/docs/screenshots/controls.png new file mode 100644 index 0000000..722987a Binary files /dev/null and b/docs/screenshots/controls.png differ diff --git a/docs/screenshots/forecast.png b/docs/screenshots/forecast.png new file mode 100644 index 0000000..4495858 Binary files /dev/null and b/docs/screenshots/forecast.png differ diff --git a/docs/screenshots/historical-scenario-mobile.png b/docs/screenshots/historical-scenario-mobile.png new file mode 100644 index 0000000..83efde2 Binary files /dev/null and b/docs/screenshots/historical-scenario-mobile.png differ diff --git a/docs/screenshots/historical-scenario.png b/docs/screenshots/historical-scenario.png new file mode 100644 index 0000000..f732fb9 Binary files /dev/null and b/docs/screenshots/historical-scenario.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png new file mode 100644 index 0000000..71f3b11 Binary files /dev/null and b/docs/screenshots/overview.png differ diff --git a/docs/screenshots/parameters.png b/docs/screenshots/parameters.png new file mode 100644 index 0000000..b81e647 Binary files /dev/null and b/docs/screenshots/parameters.png differ diff --git a/docs/screenshots/saved-runs.png b/docs/screenshots/saved-runs.png new file mode 100644 index 0000000..450c587 Binary files /dev/null and b/docs/screenshots/saved-runs.png differ diff --git a/docs/screenshots/scenario-mobile.png b/docs/screenshots/scenario-mobile.png new file mode 100644 index 0000000..0603a13 Binary files /dev/null and b/docs/screenshots/scenario-mobile.png differ diff --git a/docs/screenshots/scenario.png b/docs/screenshots/scenario.png new file mode 100644 index 0000000..277d3c0 Binary files /dev/null and b/docs/screenshots/scenario.png differ diff --git a/docs/screenshots/scheduled-forecast-mobile.png b/docs/screenshots/scheduled-forecast-mobile.png new file mode 100644 index 0000000..57395ee Binary files /dev/null and b/docs/screenshots/scheduled-forecast-mobile.png differ diff --git a/docs/screenshots/scheduled-forecast.png b/docs/screenshots/scheduled-forecast.png new file mode 100644 index 0000000..f708a95 Binary files /dev/null and b/docs/screenshots/scheduled-forecast.png differ diff --git a/docs/screenshots/wellhead-detail.png b/docs/screenshots/wellhead-detail.png new file mode 100644 index 0000000..1df634b Binary files /dev/null and b/docs/screenshots/wellhead-detail.png differ diff --git a/monorepo/README.md b/monorepo/README.md index 3478803..90799ca 100644 --- a/monorepo/README.md +++ b/monorepo/README.md @@ -1,96 +1,157 @@ -# Well Simulation Web Monorepo - -This monorepo contains the TypeScript web/API layer for the Wellhead Monitoring Simulation. - -The wider repository is heading toward a lightweight wellhead digital twin. This monorepo is not the twin core. It holds the operator dashboard, the API boundary, the shared contracts, the database access layer, and the UI components. - -## What Lives Here - -| Path | Purpose | -| ------------------- | ---------------------------------------------------------------------------------------------- | -| `apps/api` | Hono API used by the dashboard. Handles auth, dashboard reads, health, readiness, and metrics. | -| `apps/website` | React Router/Vite dashboard served behind Nginx in Docker Compose. | -| `packages/db` | Drizzle database schema, query helper, and migration tooling. | -| `packages/dto` | Shared request/response DTOs and validation schemas. | -| `packages/ui` | Shared React UI primitives used by the dashboard. | -| `packages/utils` | Shared utility functions and runtime configuration helpers. | -| `packages/tsconfig` | Shared TypeScript configuration. | - -## Current Runtime Shape - -```text -Browser - | - v -Nginx dashboard container - |-- serves React assets - |-- proxies /api/* to apps/api - v -Hono API - | - v -PostgreSQL/TimescaleDB historian +# Well simulation web and API + +This workspace contains the dashboard, public API and shared TypeScript packages. +Users can inspect wells, save analyses and review operating actions. The Python +service in `../twin_core` owns the process model and calculations. + +## What lives here + +| Path | Purpose | +| ------------------------ | ----------------------------------------------------------------------------- | +| `apps/api` | Authentication, historian reads, model requests and audited operating actions | +| `apps/website` | React Router dashboard, served through nginx in Compose | +| `packages/db` | Drizzle table definitions and database queries, mirroring the canonical SQL | +| `packages/dto` | Shared request validation and response contracts | +| `packages/ui` | Shared React components | +| `packages/utils` | Shared utilities | +| `packages/eslint-config` | Shared lint rules | +| `packages/tsconfig` | Shared TypeScript configuration | + +The database structure comes from [one SQL file](../data/sql/schema.sql). +Drizzle does not install or convert the schema. Follow the +[database update guide](../docs/operations/database-updates.md) for existing data. + +## Current workflows + +The dashboard includes fleet monitoring, trends, model status, forecasts, +scenarios from current or historical state, and saved-run history. Administrators +can configure scheduled forecasts and publish model parameters. OPERATIONS users +can review live synthetic control commands, acknowledge alarms and manage shelves. +These roles have separate authority. The seeded USER account can inspect data +and save analyses, but cannot submit operating actions. + +The browser calls `/api` on its own origin. Nginx forwards those requests to Hono. +The API reads PostgreSQL and calls twin-core over authenticated internal HTTP. +The [architecture overview](../docs/architecture/overview.md) explains the data +paths and the separation between model experiments and running controls. + +## Run the complete application + +Follow the [root quick start](../README.md#quick-start), including the required +secret settings and any existing-database upgrade. From the repository root: + +```sh +docker compose up -d --build --wait ``` -Telemetry still originates from the Python simulator and Modbus ingestion -services under `../data`. The new `twin-core` service lives at -`../twin_core`. It is separate from this monorepo and does not yet feed the -Modbus gateway or the public API. +Open . The root README lists the demo login. + +## Develop the API and dashboard on the host -## API Contracts +Use Node 22, recorded in `.nvmrc`, and pnpm 10.11.1, recorded in `package.json`. +Activate Node 22 with your version manager, then install the pinned pnpm version +if it is not already on PATH. Run from `monorepo/`: -Committed API contracts live in `../docs/openapi`: +```sh +npm install --global pnpm@10.11.1 +pnpm install --frozen-lockfile +``` -- `../docs/openapi/public-api.yaml` documents the dashboard-facing API implemented by `apps/api`. -- `../docs/openapi/twin-core-internal-api.yaml` documents the implemented - read-only twin-core endpoints and labels future operations as planned. +Keep the database, model and telemetry services in Compose. The host API needs +access to twin-core, which normally has no host port. From the repository root, +create a temporary development override and start the stack with it: -These contracts are intentionally lightweight. They are designed to support portfolio review, implementation planning, and future CI validation without pretending that the project already has a complete industrial control API. +```sh +cat > /tmp/well-simulation-dev.compose.yml <<'YAML' +services: + twin-core: + ports: + - "127.0.0.1:18000:8000" +YAML +docker compose -f docker-compose.yml -f /tmp/well-simulation-dev.compose.yml up -d --build --wait +``` -## Local Development +In the ignored root `.env`, set `DATABASE_URL` to the restricted API login on +`127.0.0.1:5434`, using the same database name and `API_DB_PASSWORD` as Compose. +The example URL contains a placeholder password; changing `API_DB_PASSWORD` +does not update that URL. Keep `JWT_SECRET` and `TWIN_INTERNAL_TOKEN` equal to +the values used by the stack. -From `monorepo/`: +Start the host API from `monorepo/`: ```sh -npx --yes pnpm@10.11.1 install -npx --yes pnpm@10.11.1 --filter @well-simulation/api dev -npx --yes pnpm@10.11.1 --filter @well-simulation/website dev +pnpm exec dotenv -e ../.env -- env TWIN_CORE_URL=http://127.0.0.1:18000 ALLOWED_ORIGINS=http://127.0.0.1:5173 COOKIE_SECURE=false pnpm --filter @well-simulation/api dev ``` -For the full stack, prefer the root Docker Compose file: +In a second terminal, also from `monorepo/`, start the dashboard: ```sh -cd .. -docker compose up --build +SERVER_BASE_URL=http://127.0.0.1:3000 pnpm --filter @well-simulation/website dev --host 127.0.0.1 --port 5173 --strictPort ``` +Open . Vite forwards `/api` to the host API on port 3000. +The explicit allowed origin lets that API accept browser form submissions. +These commands bypass Turbo, so it cannot filter out the model service settings. +The host API and Compose API share the same database; operating actions affect +the same synthetic wells. + +When finished, stop both development processes. Run the normal Compose command +without the temporary override to remove the extra twin-core host port. + ## Validation -Targeted checks used during this standards pass: +Run these commands from `monorepo/`: + +```sh +pnpm check-types +pnpm lint +pnpm format:check +pnpm test:dto +pnpm test:website +pnpm validate:openapi +``` + +OpenAPI validation also needs the Python dependencies in +`../requirements-dev.txt`. The website type task generates React Router types +before checking them; running `tsc` alone skips that step. + +API integration tests need Python on PATH and a disposable TimescaleDB test +server. Supply `TEST_POSTGRES_HOST`, `TEST_POSTGRES_PORT`, `POSTGRES_USER` and +`POSTGRES_PASSWORD` for fixture administration, plus a separate `API_DB_USER` +and `API_DB_PASSWORD`. Setup drops and recreates `well_simulation_api_test`, or +the database named by `TEST_DATABASE_NAME`. Use a dedicated test server and +never point that name at data you need to keep. The tests exercise the API +through the restricted application account. Match the pinned server +image and setup in [CI](../.github/workflows/ci.yml), then run: ```sh -./node_modules/.bin/tsc -p apps/api/tsconfig.json --noEmit -./node_modules/.bin/tsc -p apps/website/tsconfig.json --noEmit -cd apps/website && ../../node_modules/.bin/eslint . -cd packages/ui && ../../node_modules/.bin/biome lint . && ../../node_modules/.bin/tsc --noEmit +pnpm --filter @well-simulation/api test ``` -## Production-Grade Expectations - -- Keep API responses free of secrets and password hashes. -- Keep runtime configuration explicit and validated. -- Prefer typed shared DTOs over ad-hoc payload shapes. -- Keep generated framework files out of lint targets. -- Keep dashboard data access behind documented API endpoints. -- Keep twin simulation logic out of the React dashboard. - -## Known Gaps - -- The dashboard shows monitoring data but has no physical model state, - forecasts, or what-if scenario forms because those capabilities are not built. -- The API contract is hand-maintained. CI validates its structure and references, - but it does not compare every response field against running endpoints. -- Auth has login, logout, current-user, and administrator-only user creation. - Existing sessions are not revoked immediately when an administrator changes a role. -- Twin-core calculates synthetic process state, but its output is not yet the - source for Modbus, the historian, or the dashboard. +With a healthy Compose application on port 8090, run browser checks: + +```sh +pnpm --filter @well-simulation/website exec playwright install --with-deps chromium +pnpm --filter @well-simulation/website test:browser +``` + +Use `DASHBOARD_TEST_URL` for a separate test installation. The browser suite saves +synthetic analyses; it is not a read-only probe. See the +[verification records](../docs/README.md#interface-design-and-verification) +for real and mocked workflow coverage and accessibility limits. + +## Contracts and remaining limits + +The [public and internal API specifications](../docs/openapi/README.md) describe +the implemented routes. CI checks their structure and references. Endpoint tests +check behavior separately; neither establishes exhaustive response-schema coverage. + +Account creation, login and logout exist. Public account editing, role changes, +alarm-rule editing and general asset administration do not. Ordinary read guards +use the session's role claim. Sensitive operating paths recheck current account +authority; scheduled jobs also check their owner's authority before saving. +Logout revokes every existing session for that account. + +Keep model equations in Python, validation in shared contracts and data access +behind the API. See [production readiness](../docs/architecture/production-readiness.md) +for the current local evidence and open delivery items. diff --git a/monorepo/apps/api/src/_app.ts b/monorepo/apps/api/src/_app.ts index 10430e1..4edb69b 100644 --- a/monorepo/apps/api/src/_app.ts +++ b/monorepo/apps/api/src/_app.ts @@ -9,8 +9,17 @@ import { customRateLimit } from './middleware/rate-limiter'; import { originGuard } from './middleware/csrf'; import { requestId } from './middleware/request-id'; import { dashboardRouter } from './routers/dashboard'; +import { twinRouter } from './routers/twin'; +import { controlsRouter } from './routers/controls'; +import { alarmsRouter } from './routers/alarms'; import { errorResponse } from './utils/handler'; -import { httpDuration, httpRequests, registry } from './metrics'; +import { + databaseReady, + httpDuration, + httpRequests, + readinessCheckedAt, + registry, +} from './metrics'; const app = new Hono(); @@ -71,6 +80,8 @@ app.get('/metrics', async (c) => { app.get('/ready', async (c) => { try { await db.execute(sql`select 1`); + databaseReady.set(1); + readinessCheckedAt.set(Date.now() / 1000); return c.json({ status: 'ready', service: 'api', @@ -78,6 +89,8 @@ app.get('/ready', async (c) => { timestamp: new Date().toISOString(), }); } catch { + databaseReady.set(0); + readinessCheckedAt.set(Date.now() / 1000); return c.json( { status: 'not_ready', @@ -100,6 +113,9 @@ app.onError((err, c) => errorResponse(c, err)); export const appRouter = app .route('/auth', authRouter) - .route('/dashboard', dashboardRouter); + .route('/dashboard', dashboardRouter) + .route('/alarms', alarmsRouter) + .route('/twin', twinRouter) + .route('/twin', controlsRouter); export type AppType = typeof appRouter; diff --git a/monorepo/apps/api/src/alarm-read.ts b/monorepo/apps/api/src/alarm-read.ts new file mode 100644 index 0000000..5b1e7a2 --- /dev/null +++ b/monorepo/apps/api/src/alarm-read.ts @@ -0,0 +1,233 @@ +import { HTTPException } from 'hono/http-exception'; +import { db, sql, type SQL } from '@well-simulation/db/query'; +import { ALARM_SEVERITIES } from '@well-simulation/dto/res/dashboard'; +import { + ALARM_PAGE_SIZE, + eventCursorSchema, + shelfCursorSchema, + type AlarmQuery, + type ShelfQuery, +} from '@well-simulation/dto/req/alarms'; +import { + alarmDetailSchema, + alarmEventSchema, + alarmListSchema, + alarmReceiptSchema, + alarmShelfListSchema, + alarmTargetSchema, +} from '@well-simulation/dto/res/alarms'; +import type { Transaction } from './transaction-authority'; + +/** Convert in SQL before pg can reduce a timestamp to JavaScript milliseconds. */ +export const exactTime = (value: SQL) => + sql`to_char((${value}) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`; +export const targetJson = sql`jsonb_build_object( + 'wellheadId', w.wellhead_id, 'wellheadName', w.name, 'locationName', l.name, + 'ruleId', r.alarm_rule_id, 'parameterName', p.display_name, 'unit', p.canonical_unit, + 'operator', r.operator, 'threshold', r.threshold_value, 'severity', r.severity_level, + 'triggerAfter', r.trigger_after, 'clearAfter', r.clear_after)`; +const eventFrom = sql`FROM alarmevent e + JOIN wellhead w ON w.wellhead_id=e.wellhead_id + JOIN location l ON l.location_id=w.location_id + JOIN alarmrule r ON r.alarm_rule_id=e.alarm_rule_id + JOIN parametertype p ON p.parameter_type_id=r.parameter_type_id + LEFT JOIN alarm_acknowledgement a ON a.event_id=e.event_id AND a.triggered_at=e.triggered_at + LEFT JOIN alarm_action aa ON aa.action_id=a.action_id + LEFT JOIN alarm_shelf s ON s.wellhead_id=e.wellhead_id AND s.alarm_rule_id=e.alarm_rule_id + LEFT JOIN alarm_action sa ON sa.action_id=s.action_id`; +const shelfFrom = sql`FROM alarm_shelf s + JOIN wellhead w ON w.wellhead_id=s.wellhead_id JOIN location l ON l.location_id=w.location_id + JOIN alarmrule r ON r.alarm_rule_id=s.alarm_rule_id + JOIN parametertype p ON p.parameter_type_id=r.parameter_type_id + JOIN alarm_action sa ON sa.action_id=s.action_id`; +const priority = sql`CASE e.severity_level ${sql.join( + ALARM_SEVERITIES.map((severity, rank) => sql`WHEN ${severity} THEN ${rank}::int`), + sql` ` +)} ELSE ${ALARM_SEVERITIES.length}::int END`; +const shelfJson = (at: string) => sql`jsonb_build_object('revision',s.revision, + 'until',${exactTime(sql`s.shelved_until`)}, 'active',coalesce(s.shelved_until > ${at}::timestamptz,false), + 'updatedAt',${exactTime(sql`s.updated_at`)},'actorName',sa.actor_name,'reason',sa.input->>'reason')`; +const eventJson = (at: string) => sql`jsonb_build_object( + 'eventId',e.event_id::text,'triggeredAt',${exactTime(sql`e.triggered_at`)}, + 'clearedAt',${exactTime(sql`e.cleared_at`)},'severity',e.severity_level,'triggeredValue',e.triggered_value, + 'target',${targetJson}, 'acknowledgement',CASE WHEN a.action_id IS NULL THEN NULL ELSE + jsonb_build_object('at',${exactTime(sql`a.acknowledged_at`)},'actorName',aa.actor_name,'reason',aa.input->>'reason') END, + 'shelf',CASE WHEN s.revision IS NULL THEN NULL ELSE ${shelfJson(at)} END)`; + +/** Counts and rows share one snapshot and one database observation time. */ +async function readSnapshot( + read: (tx: Transaction, at: string) => Promise +): Promise { + return db.transaction( + async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '3000ms'`); + const clock = await tx.execute<{ at: string }>( + sql`SELECT ${exactTime(sql`transaction_timestamp()`)} AS at` + ); + return read(tx, clock.rows[0]!.at); + }, + { isolationLevel: 'repeatable read', accessMode: 'read only' } + ); +} +function decode( + value: string | undefined, + schema: { parse(value: unknown): T } +): T | null { + if (!value) return null; + try { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('encoding'); + return schema.parse(JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))); + } catch { + throw new HTTPException(400, { message: 'Invalid alarm page cursor' }); + } +} +const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString('base64url'); +const wellFilter = (wellheadId: number | undefined) => + wellheadId ? sql`w.wellhead_id=${wellheadId}` : sql`TRUE`; + +async function pageSummary(tx: Transaction, at: string, wellheadId?: number) { + const attention = sql`a.action_id IS NULL AND (s.shelved_until IS NULL OR s.shelved_until <= ${at}::timestamptz)`; + const totals = await tx.execute(sql`SELECT count(*)::int AS retained, + count(*) FILTER (WHERE e.cleared_at IS NULL)::int AS active, + count(*) FILTER (WHERE e.cleared_at IS NULL AND e.severity_level='CRITICAL')::int AS critical, + count(*) FILTER (WHERE ${attention})::int AS attention ${eventFrom} WHERE ${wellFilter(wellheadId)}`); + const shelves = await tx.execute<{ count: number }>( + sql`SELECT count(*)::int AS count ${shelfFrom} WHERE ${wellFilter(wellheadId)} AND s.shelved_until > ${at}::timestamptz` + ); + const wells = await tx.execute( + sql`SELECT wellhead_id AS "wellheadId",name AS "wellheadName" FROM wellhead ORDER BY name,wellhead_id` + ); + return { + summary: { ...totals.rows[0], shelves: shelves.rows[0]!.count }, + wells: wells.rows, + }; +} + +export async function listAlarms(query: AlarmQuery) { + const cursor = decode(query.cursor, eventCursorSchema); + if ( + cursor && + (cursor.view !== query.view || cursor.wellheadId !== (query.wellheadId ?? null)) + ) + throw new HTTPException(400, { message: 'Cursor belongs to another alarm view' }); + return readSnapshot(async (tx, at) => { + const attention = sql`a.action_id IS NULL AND (s.shelved_until IS NULL OR s.shelved_until <= ${at}::timestamptz)`; + const header = await pageSummary(tx, at, query.wellheadId); + const filter = + query.view === 'attention' + ? attention + : query.view === 'active' + ? sql`e.cleared_at IS NULL` + : sql`TRUE`; + const after = !cursor + ? sql`TRUE` + : query.view === 'history' + ? sql`(e.triggered_at,e.event_id) < (${cursor.at}::timestamptz,${cursor.id}::bigint)` + : sql`(${priority},e.triggered_at,e.event_id) > (${cursor.rank},${cursor.at}::timestamptz,${cursor.id}::bigint)`; + const order = + query.view === 'history' + ? sql`e.triggered_at DESC,e.event_id DESC` + : sql`${priority},e.triggered_at,e.event_id`; + const result = await tx.execute(sql`SELECT ${eventJson(at)} AS item ${eventFrom} + WHERE ${wellFilter(query.wellheadId)} AND ${filter} AND ${after} ORDER BY ${order} LIMIT ${ALARM_PAGE_SIZE + 1}`); + const items = result.rows + .slice(0, ALARM_PAGE_SIZE) + .map((row) => alarmEventSchema.parse(row.item)); + const last = items.at(-1); + const rank = last + ? ALARM_SEVERITIES.findIndex((value) => value === last.severity) + : -1; + const nextCursor = + result.rows.length > ALARM_PAGE_SIZE && last + ? encode({ + wellheadId: query.wellheadId ?? null, + view: query.view, + rank: rank < 0 ? ALARM_SEVERITIES.length : rank, + at: last.triggeredAt, + id: last.eventId, + }) + : null; + return alarmListSchema.parse({ + observedAt: at, + ...header, + items, + nextCursor, + }); + }); +} + +export async function listShelves(query: ShelfQuery) { + const cursor = decode(query.cursor, shelfCursorSchema); + if (cursor && cursor.wellheadId !== (query.wellheadId ?? null)) + throw new HTTPException(400, { message: 'Cursor belongs to another well filter' }); + return readSnapshot(async (tx, at) => { + const header = await pageSummary(tx, at, query.wellheadId); + const condition = sql`${wellFilter(query.wellheadId)} AND s.shelved_until > ${at}::timestamptz`; + const count = await tx.execute<{ count: number }>( + sql`SELECT count(*)::int AS count ${shelfFrom} WHERE ${condition}` + ); + const after = cursor + ? sql`(s.wellhead_id,s.alarm_rule_id)>(${cursor.well},${cursor.rule})` + : sql`TRUE`; + const rows = await tx.execute( + sql`SELECT ${targetJson} AS target, ${shelfJson(at)} AS state ${shelfFrom} WHERE ${condition} AND ${after} ORDER BY s.wellhead_id,s.alarm_rule_id LIMIT ${ALARM_PAGE_SIZE + 1}` + ); + const items = rows.rows.slice(0, ALARM_PAGE_SIZE); + const last = items.at(-1); + const target = last ? alarmTargetSchema.parse(last.target) : null; + return alarmShelfListSchema.parse({ + ...header, + observedAt: at, + items, + total: count.rows[0]!.count, + nextCursor: + rows.rows.length > ALARM_PAGE_SIZE && target + ? encode({ + wellheadId: query.wellheadId ?? null, + well: target.wellheadId, + rule: target.ruleId, + }) + : null, + }); + }); +} + +export async function readAlarmEvent( + tx: Transaction, + at: string, + eventId: string, + triggeredAt: string +) { + const result = await tx.execute( + sql`SELECT ${eventJson(at)} AS item ${eventFrom} WHERE e.event_id=${eventId}::bigint AND e.triggered_at=${triggeredAt}::timestamptz` + ); + if (!result.rows[0]) + throw new HTTPException(404, { + message: 'Alarm event is no longer retained or does not exist', + }); + return alarmEventSchema.parse(result.rows[0].item); +} +export async function alarmDetail(eventId: string, triggeredAt: string) { + return readSnapshot(async (tx, at) => { + const event = await readAlarmEvent(tx, at, eventId, triggeredAt); + const history = await tx.execute( + sql`SELECT result FROM alarm_action WHERE wellhead_id=${event.target.wellheadId} AND alarm_rule_id=${event.target.ruleId} ORDER BY occurred_at DESC,action_id DESC LIMIT 20` + ); + return alarmDetailSchema.parse({ + observedAt: at, + event, + actions: history.rows.map((row) => row.result), + }); + }); +} +export async function ownAlarmReceipt(actorId: number, requestKey: string) { + const rows = await db.execute( + sql`SELECT result FROM alarm_action WHERE actor_user_id=${actorId} AND request_key=${requestKey}::uuid` + ); + if (!rows.rows[0]) + throw new HTTPException(404, { + message: 'No committed action found for this request key', + }); + return alarmReceiptSchema.parse(rows.rows[0].result); +} diff --git a/monorepo/apps/api/src/alarm-write.ts b/monorepo/apps/api/src/alarm-write.ts new file mode 100644 index 0000000..014bcbd --- /dev/null +++ b/monorepo/apps/api/src/alarm-write.ts @@ -0,0 +1,184 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { and, db, eq, sql } from '@well-simulation/db/query'; +import { + alarmAction, + alarmAcknowledgement, + alarmShelf, +} from '@well-simulation/db/schemas/alarms'; +import { auditEvent } from '@well-simulation/db/schemas/audit'; +import type { AlarmActionInput } from '@well-simulation/dto/req/alarms'; +import { alarmReceiptSchema, alarmTargetSchema } from '@well-simulation/dto/res/alarms'; +import { currentActor } from './transaction-authority'; +import { currentRequestId } from './middleware/request-id'; +import { errorCode } from './utils/database-error'; +import { exactTime, readAlarmEvent, targetJson } from './alarm-read'; + +const conflict = () => + new HTTPException(409, { + message: 'Alarm state changed. Read it again and review your action.', + }); +const auditActions = { + acknowledge: 'alarm.acknowledged', + shelve: 'alarm.shelved', + unshelve: 'alarm.unshelved', +} as const; + +/** Commit the receipt, lifecycle state and audit together under current authority. */ +export async function applyAlarmAction( + c: Context, + input: AlarmActionInput, + requestKey: string +) { + const inputHash = createHash('sha256').update(JSON.stringify(input)).digest('hex'); + try { + return await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`); + await tx.execute(sql`SET LOCAL statement_timeout = '3000ms'`); + const actor = await currentActor(tx, c, 'alarm:acknowledge'); + const [previous] = await tx + .select() + .from(alarmAction) + .where( + and( + eq(alarmAction.actorUserId, actor.id), + eq(alarmAction.requestKey, requestKey) + ) + ); + if (previous) { + if (previous.inputHash !== inputHash) + throw new HTTPException(409, { + message: 'This request key already belongs to different reviewed input', + }); + return alarmReceiptSchema.parse(previous.result); + } + const initialClock = await tx.execute<{ at: string }>( + sql`SELECT ${exactTime(sql`clock_timestamp()`)} AS at` + ); + const event = + input.action === 'acknowledge' + ? await readAlarmEvent( + tx, + initialClock.rows[0]!.at, + input.eventId, + input.triggeredAt + ) + : null; + if (event?.acknowledgement) throw conflict(); + const target = + event?.target ?? + (await (async () => { + if (input.action === 'acknowledge') throw new Error('Missing event target'); + const row = + await tx.execute(sql`SELECT ${targetJson} AS target FROM wellhead w + JOIN location l ON l.location_id=w.location_id CROSS JOIN alarmrule r + JOIN parametertype p ON p.parameter_type_id=r.parameter_type_id + WHERE w.wellhead_id=${input.wellheadId} AND r.alarm_rule_id=${input.ruleId}`); + if (!row.rows[0]) + throw new HTTPException(404, { message: 'Well or alarm rule not found' }); + return alarmTargetSchema.parse(row.rows[0].target); + })()); + const shelfKey = and( + eq(alarmShelf.wellheadId, target.wellheadId), + eq(alarmShelf.ruleId, target.ruleId) + ); + const current = + input.action === 'acknowledge' + ? undefined + : (await tx.select().from(alarmShelf).where(shelfKey).for('update'))[0]; + if ( + input.action !== 'acknowledge' && + (current?.revision ?? 0) !== input.expectedRevision + ) + throw conflict(); + // Calculate the deadline after locks, once, so the receipt and state agree. + const duration = input.action === 'shelve' ? input.durationMinutes : 0; + const clock = await tx.execute<{ + at: string; + until: string; + active: boolean; + }>(sql`WITH clock AS (SELECT clock_timestamp() AS at) + SELECT ${exactTime(sql`at`)} AS at,${exactTime(sql`at + ${duration} * interval '1 minute'`)} AS until, + coalesce(${current?.shelvedUntil ?? null}::timestamptz > at,false) AS active FROM clock`); + const now = clock.rows[0]!; + if (input.action === 'unshelve' && !now.active) throw conflict(); + const receipt = alarmReceiptSchema.parse({ + actionId: randomUUID(), + action: input.action, + occurredAt: now.at, + actorUserId: actor.id, + actorName: actor.userName, + reason: input.reason, + target, + eventId: event?.eventId ?? null, + triggeredAt: event?.triggeredAt ?? null, + revision: input.action === 'acknowledge' ? null : input.expectedRevision + 1, + shelvedUntil: input.action === 'shelve' ? now.until : null, + }); + await tx.insert(alarmAction).values({ + actionId: receipt.actionId, + actorUserId: actor.id, + actorName: actor.userName, + actorRole: 'OPERATIONS', + requestKey, + inputHash, + action: input.action, + wellheadId: target.wellheadId, + ruleId: target.ruleId, + occurredAt: receipt.occurredAt, + input, + result: receipt, + }); + if (input.action === 'acknowledge') { + await tx.insert(alarmAcknowledgement).values({ + eventId: BigInt(input.eventId), + triggeredAt: input.triggeredAt, + acknowledgedAt: receipt.occurredAt, + actionId: receipt.actionId, + }); + } else { + const values = { + revision: input.expectedRevision + 1, + shelvedUntil: receipt.shelvedUntil, + updatedAt: receipt.occurredAt, + actionId: receipt.actionId, + }; + if (current) await tx.update(alarmShelf).set(values).where(shelfKey); + else + await tx.insert(alarmShelf).values({ + ...values, + wellheadId: target.wellheadId, + ruleId: target.ruleId, + }); + } + await tx.insert(auditEvent).values({ + occurredAt: receipt.occurredAt, + actorUserId: actor.id, + action: auditActions[input.action], + subjectType: 'alarm_action', + subjectId: receipt.actionId, + outcome: 'success', + requestId: currentRequestId(c), + detail: receipt, + }); + return receipt; + }); + } catch (error) { + if (error instanceof HTTPException) throw error; + if (errorCode(error) === '23505') throw conflict(); + console.error( + JSON.stringify({ + level: 'error', + service: 'api', + event: 'alarm_action_failed', + requestId: currentRequestId(c), + code: errorCode(error), + }) + ); + throw new HTTPException(503, { + message: + 'The action outcome is unavailable. Check this request key before retrying.', + }); + } +} diff --git a/monorepo/apps/api/src/audit.ts b/monorepo/apps/api/src/audit.ts index 7554b85..ed57ca9 100644 --- a/monorepo/apps/api/src/audit.ts +++ b/monorepo/apps/api/src/audit.ts @@ -19,7 +19,9 @@ export type AuditAction = | 'user.create' | 'user.disable' | 'user.role_change' - | 'config.change'; + | 'config.change' + | 'twin.forecast' + | 'twin.scenario'; export type AuditOutcome = 'success' | 'failure'; diff --git a/monorepo/apps/api/src/config.ts b/monorepo/apps/api/src/config.ts index 997933f..1934d78 100644 --- a/monorepo/apps/api/src/config.ts +++ b/monorepo/apps/api/src/config.ts @@ -26,11 +26,38 @@ function readJwtSecret(): string { return secret; } +function readTwinInternalToken(): string { + const token = required('TWIN_INTERNAL_TOKEN'); + if (token.length < MIN_SECRET_LENGTH) { + throw new Error( + `TWIN_INTERNAL_TOKEN must be at least ${MIN_SECRET_LENGTH} characters` + ); + } + return token; +} + +function readTwinCoreUrl(): string { + const raw = process.env.TWIN_CORE_URL ?? 'http://twin-core:8000'; + const url = new URL(raw); + if ( + url.protocol !== 'http:' || + !url.hostname || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error('TWIN_CORE_URL must be an HTTP service origin'); + } + return url.origin; +} + /** * Whether session cookies carry the `Secure` flag. * * Previously tied to `NODE_ENV === 'production'`, which the compose stack sets - * while serving plain HTTP on port 8082. That works only because browsers treat + * while serving plain HTTP on port 8090. That works only because browsers treat * localhost as a secure context; on any real host without TLS the browser drops * the cookie and login silently fails. Driving it from an explicit variable * makes the deployment state the truth rather than inferring it. @@ -58,6 +85,8 @@ function readAllowedOrigins(): string[] { export const config = { jwtSecret: readJwtSecret(), + twinInternalToken: readTwinInternalToken(), + twinCoreUrl: readTwinCoreUrl(), // Named on both sign and verify, so a token cannot choose its own algorithm. jwtAlgorithm: 'HS256', cookieSecure: readCookieSecure(), diff --git a/monorepo/apps/api/src/control-service.ts b/monorepo/apps/api/src/control-service.ts new file mode 100644 index 0000000..fdfd468 --- /dev/null +++ b/monorepo/apps/api/src/control-service.ts @@ -0,0 +1,587 @@ +import { errorCode } from './utils/database-error'; +/** Atomic command and parameter lifecycles, with current authority and audit. */ +import { randomUUID } from 'node:crypto'; +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { and, asc, db, desc, eq, sql } from '@well-simulation/db/query'; +import { auditEvent } from '@well-simulation/db/schemas/audit'; +import { wellhead, wellheadModelParameter } from '@well-simulation/db/schemas/schema'; +import { + controlCommand, + controlCommandEvent, + modelParameterChange, + wellheadControlState, +} from '@well-simulation/db/schemas/controls'; +import { currentActor } from './transaction-authority'; +import type { ControlDraft, ParameterDraft } from '@well-simulation/dto/req/controls'; +import { + runningControls, + parameterIdentity, + parameterCatalogue, + validateParameter, +} from './twin-control-client'; +import { sessionUserId } from './middleware/authorize'; +import { currentRequestId } from './middleware/request-id'; +import { + commandSummarySchema, + parameterSummarySchema, +} from '@well-simulation/dto/res/controls'; + +type Transaction = Parameters[0]>[0]; +type Command = typeof controlCommand.$inferSelect; +type ParameterChange = typeof modelParameterChange.$inferSelect; + +function requireRunning(state: Awaited>): void { + if ( + state.status !== 'running' || + state.lastTickAt === null || + Math.abs(Date.now() - Date.parse(state.lastTickAt)) > 3000 + ) + throw new HTTPException(503, { message: 'Model is not current' }); +} + +/** Transactional audit: throwing here rolls back the accepted change too. */ +async function audit( + tx: Transaction, + c: Context, + action: string, + subjectType: string, + subjectId: string +): Promise { + await tx.insert(auditEvent).values({ + occurredAt: sql`clock_timestamp()`, + actorUserId: sessionUserId(c), + action, + subjectType, + subjectId, + outcome: 'success', + requestId: currentRequestId(c), + detail: {}, + }); +} + +async function commandEvent( + tx: Transaction, + c: Context, + commandId: string, + eventType: string, + reasonCode: string +): Promise { + await tx.insert(controlCommandEvent).values({ + commandId, + eventType, + actorUserId: sessionUserId(c), + reasonCode, + requestId: currentRequestId(c), + }); + await audit(tx, c, `control.${eventType}`, 'control_command', commandId); +} + +/** Avoid driver errors containing a command reason or a credential in logs. */ +async function transaction( + c: Context, + operation: (tx: Transaction) => Promise +): Promise { + try { + return await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`); + await tx.execute(sql`SET LOCAL statement_timeout = '3000ms'`); + return operation(tx); + }); + } catch (error) { + if (error instanceof HTTPException) throw error; + const code = errorCode(error); + console.error( + JSON.stringify({ + level: 'error', + service: 'api', + event: 'control_transaction_failed', + code, + requestId: currentRequestId(c), + }) + ); + if (code === '23505') + throw new HTTPException(409, { + message: 'A conflicting proposal or queued command exists', + }); + throw new HTTPException(503, { + message: 'Write outcome unavailable; read the proposal status before retrying', + }); + } +} + +async function activeRow(tx: Transaction, wellheadId: number) { + await tx.execute(sql`SELECT lock_control_state(${wellheadId})`); + const [row] = await tx + .select() + .from(wellheadControlState) + .where(eq(wellheadControlState.wellheadId, wellheadId)); + if (!row) + throw new HTTPException(503, { message: 'Active controls are not initialized' }); + return row; +} + +function assertSameDraft(row: Command, input: ControlDraft): void { + const targets = input.targets; + if ( + row.wellheadId !== input.wellheadId || + row.expectedRevision !== input.expectedRevision || + row.reason !== input.reason || + row.chokePositionPercent !== (targets.chokePositionPercent ?? null) || + row.masterValveOpen !== (targets.masterValveOpen ?? null) || + row.wingValveOpen !== (targets.wingValveOpen ?? null) || + row.pumpOn !== (targets.pumpOn ?? null) || + row.emergencyShutdown !== (targets.emergencyShutdown ?? null) + ) + throw new HTTPException(409, { + message: 'Idempotency key already used for a different proposal', + }); +} + +async function expireCommand( + tx: Transaction, + c: Context, + row: Command +): Promise { + const [expired] = await tx + .update(controlCommand) + .set({ + status: 'expired', + resultAt: sql`clock_timestamp()`, + resultReason: 'expired', + }) + .where( + and( + eq(controlCommand.commandId, row.commandId), + sql`${controlCommand.expiresAt} <= clock_timestamp()`, + sql`${controlCommand.status} IN ('draft','queued')` + ) + ) + .returning(); + if (expired) await commandEvent(tx, c, row.commandId, 'expired', 'expired'); + return expired ?? row; +} + +export async function draftCommand( + c: Context, + input: ControlDraft, + idempotencyKey: string +): Promise { + return transaction(c, async (tx) => { + const actor = await currentActor(tx, c, 'control:issue'); + const [existing] = await tx + .select() + .from(controlCommand) + .where( + and( + eq(controlCommand.createdBy, actor.id), + eq(controlCommand.idempotencyKey, idempotencyKey) + ) + ) + .for('update'); + if (existing) { + assertSameDraft(existing, input); + return expireCommand(tx, c, existing); + } + const prior = await tx + .select() + .from(controlCommand) + .where( + and( + eq(controlCommand.createdBy, actor.id), + eq(controlCommand.wellheadId, input.wellheadId), + eq(controlCommand.status, 'draft') + ) + ) + .for('update'); + for (const row of prior) await expireCommand(tx, c, row); + const running = await runningControls(input.wellheadId); + requireRunning(running); + const active = await activeRow(tx, input.wellheadId); + if ( + active.revision !== input.expectedRevision || + running.revision !== active.revision || + active.modelVersion !== running.modelVersion || + active.parameterFingerprint !== running.parameterFingerprint + ) + throw new HTTPException(409, { + message: 'Active controls changed; refresh before drafting', + }); + const [row] = await tx + .insert(controlCommand) + .values({ + commandId: randomUUID(), + createdBy: actor.id, + idempotencyKey, + wellheadId: input.wellheadId, + reason: input.reason, + expectedRevision: input.expectedRevision, + modelVersion: running.modelVersion, + parameterFingerprint: running.parameterFingerprint, + ...input.targets, + requestId: currentRequestId(c), + }) + .returning(); + if (!row) throw new Error('Draft insert failed'); + await commandEvent(tx, c, row.commandId, 'draft', 'created'); + return row; + }); +} + +export async function changeCommand( + c: Context, + commandId: string, + action: 'activate' | 'cancel', + expectedRevision?: number +): Promise { + return transaction(c, async (tx) => { + const actor = await currentActor(tx, c, 'control:issue'); + const [found] = await tx + .select() + .from(controlCommand) + .where(eq(controlCommand.commandId, commandId)) + .for('update'); + if (!found) throw new HTTPException(404, { message: 'Command not found' }); + if (found.createdBy !== actor.id) + throw new HTTPException(403, { + message: 'Only the creator may change this command', + }); + const row = await expireCommand(tx, c, found); + if (row.status !== 'draft' && (action === 'activate' || row.status !== 'queued')) + return row; + if (action === 'cancel') { + const [cancelled] = await tx + .update(controlCommand) + .set({ + status: 'cancelled', + resultAt: sql`clock_timestamp()`, + resultReason: 'creator_cancelled', + }) + .where(eq(controlCommand.commandId, commandId)) + .returning(); + await commandEvent(tx, c, commandId, 'cancelled', 'creator_cancelled'); + return cancelled!; + } + const running = await runningControls(row.wellheadId); + requireRunning(running); + const active = await activeRow(tx, row.wellheadId); + if ( + row.expectedRevision !== expectedRevision || + active.revision !== expectedRevision || + running.revision !== expectedRevision || + row.modelVersion !== running.modelVersion || + row.parameterFingerprint !== running.parameterFingerprint || + row.modelVersion !== active.modelVersion || + row.parameterFingerprint !== active.parameterFingerprint + ) + throw new HTTPException(409, { message: 'Model or active revision changed' }); + if (active.emergencyShutdown && row.emergencyShutdown === null) + throw new HTTPException(409, { + message: 'Reset emergency shutdown in a separate command first', + }); + const [queued] = await tx + .update(controlCommand) + .set({ + status: 'queued', + queuedAt: sql`clock_timestamp()`, + expiresAt: sql`clock_timestamp() + interval '30 seconds'`, + activatedBy: actor.id, + activatedRole: actor.role, + activatedSessionVersion: actor.sessionVersion, + }) + .where(eq(controlCommand.commandId, commandId)) + .returning(); + await commandEvent(tx, c, commandId, 'queued', 'activated'); + return queued!; + }); +} + +export async function commandStatus(c: Context, commandId: string) { + return transaction(c, async (tx) => { + await currentActor(tx, c, 'control:issue'); + const [found] = await tx + .select() + .from(controlCommand) + .where(eq(controlCommand.commandId, commandId)) + .for('update'); + if (!found) throw new HTTPException(404, { message: 'Command not found' }); + const row = await expireCommand(tx, c, found); + const events = await tx + .select() + .from(controlCommandEvent) + .where(eq(controlCommandEvent.commandId, commandId)) + .orderBy(asc(controlCommandEvent.eventId)); + return { ...row, events }; + }); +} + +export async function controlStatus(wellheadId: number) { + const running = await runningControls(wellheadId); + const [active] = await db + .select() + .from(wellheadControlState) + .where(eq(wellheadControlState.wellheadId, wellheadId)); + const [queued] = await db + .select({ commandId: controlCommand.commandId }) + .from(controlCommand) + .where( + and( + eq(controlCommand.wellheadId, wellheadId), + eq(controlCommand.status, 'queued') + ) + ); + return { + active: active ?? null, + running, + pendingCommandId: queued?.commandId ?? null, + }; +} + +/** Let an administrator obtain revision IDs without hand-entered SQL. */ +export async function parameterStatus(c: Context, wellheadId: number) { + await transaction(c, (tx) => currentActor(tx, c, 'asset:manage')); + // Network waits must not hold the account lock needed by outcome recovery. + const [identity, catalogue, running] = await Promise.all([ + parameterIdentity(wellheadId), + parameterCatalogue(wellheadId), + runningControls(wellheadId).catch(() => null), + ]); + if (identity.modelVersion !== catalogue.modelVersion) + throw new HTTPException(503, { + message: 'Model version changed; refresh the view', + }); + return transaction(c, async (tx) => { + await currentActor(tx, c, 'asset:manage'); + const overrides = await tx + .selectDistinctOn([wellheadModelParameter.parameterKey]) + .from(wellheadModelParameter) + .where( + and( + eq(wellheadModelParameter.wellheadId, wellheadId), + eq(wellheadModelParameter.modelVersion, identity.modelVersion) + ) + ) + .orderBy( + asc(wellheadModelParameter.parameterKey), + desc(wellheadModelParameter.validFrom), + desc(wellheadModelParameter.parameterOverrideId) + ); + return { ...identity, latestOverrides: overrides, running, catalogue }; + }); +} + +/** Recover a person's proposals without requiring an available model service. */ +export async function proposalHistory( + c: Context, + wellheadId: number, + kind: 'control' | 'parameter', + query: { offset: number; idempotencyKey?: string } +) { + return transaction(c, async (tx) => { + const actor = await currentActor( + tx, + c, + kind === 'control' ? 'control:issue' : 'asset:manage' + ); + const table = kind === 'control' ? controlCommand : modelParameterChange; + const id = + kind === 'control' ? controlCommand.commandId : modelParameterChange.changeId; + const rows = await tx + .select() + .from(table) + .where( + and( + eq(table.wellheadId, wellheadId), + eq(table.createdBy, actor.id), + query.idempotencyKey + ? eq(table.idempotencyKey, query.idempotencyKey) + : undefined + ) + ) + .orderBy(desc(table.createdAt), desc(id)) + .limit(21) + .offset(query.offset); + const items = rows + .slice(0, 20) + .map((row) => + kind === 'control' + ? commandSummarySchema.parse(row) + : parameterSummarySchema.parse(row) + ); + return { items, offset: query.offset, hasMore: rows.length > 20 }; + }); +} + +async function latestOverride( + tx: Transaction, + input: Pick +) { + const [row] = await tx + .select() + .from(wellheadModelParameter) + .where( + and( + eq(wellheadModelParameter.wellheadId, input.wellheadId), + eq(wellheadModelParameter.modelVersion, input.modelVersion), + eq(wellheadModelParameter.parameterKey, input.parameterKey) + ) + ) + .orderBy( + desc(wellheadModelParameter.validFrom), + desc(wellheadModelParameter.parameterOverrideId) + ) + .limit(1); + return row?.parameterOverrideId ?? null; +} + +function assertSameParameter(row: ParameterChange, input: ParameterDraft): void { + if ( + row.wellheadId !== input.wellheadId || + row.modelVersion !== input.modelVersion || + row.parameterKey !== input.parameterKey || + row.value !== input.value || + row.unit !== input.unit || + row.reason !== input.reason || + row.expectedOverrideId !== input.expectedOverrideId + ) + throw new HTTPException(409, { + message: 'Idempotency key already used for a different proposal', + }); +} + +async function expireParameter( + tx: Transaction, + c: Context, + row: ParameterChange +): Promise { + const [expired] = await tx + .update(modelParameterChange) + .set({ status: 'expired', resultAt: sql`clock_timestamp()` }) + .where( + and( + eq(modelParameterChange.changeId, row.changeId), + eq(modelParameterChange.status, 'draft'), + sql`${modelParameterChange.expiresAt} <= clock_timestamp()` + ) + ) + .returning(); + if (expired) + await audit(tx, c, 'parameter.expired', 'model_parameter_change', row.changeId); + return expired ?? row; +} + +export async function draftParameter( + c: Context, + input: ParameterDraft, + idempotencyKey: string +): Promise { + return transaction(c, async (tx) => { + const actor = await currentActor(tx, c, 'asset:manage'); + const [existing] = await tx + .select() + .from(modelParameterChange) + .where( + and( + eq(modelParameterChange.createdBy, actor.id), + eq(modelParameterChange.idempotencyKey, idempotencyKey) + ) + ) + .for('update'); + if (existing) { + assertSameParameter(existing, input); + return expireParameter(tx, c, existing); + } + const prior = await tx + .select() + .from(modelParameterChange) + .where( + and( + eq(modelParameterChange.createdBy, actor.id), + eq(modelParameterChange.wellheadId, input.wellheadId), + eq(modelParameterChange.parameterKey, input.parameterKey), + eq(modelParameterChange.status, 'draft') + ) + ) + .for('update'); + for (const row of prior) await expireParameter(tx, c, row); + await tx.execute(sql`SELECT pg_advisory_xact_lock(8038, ${input.wellheadId})`); + if ((await latestOverride(tx, input)) !== input.expectedOverrideId) + throw new HTTPException(409, { message: 'Parameter override changed' }); + await validateParameter(input); + const [row] = await tx + .insert(modelParameterChange) + .values({ ...input, changeId: randomUUID(), createdBy: actor.id, idempotencyKey }) + .returning(); + if (!row) throw new Error('Parameter draft insert failed'); + await audit(tx, c, 'parameter.draft', 'model_parameter_change', row.changeId); + return row; + }); +} + +export async function changeParameter( + c: Context, + changeId: string, + action: 'publish' | 'cancel' | 'read' +) { + return transaction(c, async (tx) => { + const actor = await currentActor(tx, c, 'asset:manage'); + const [found] = await tx + .select() + .from(modelParameterChange) + .where(eq(modelParameterChange.changeId, changeId)) + .for('update'); + if (!found) throw new HTTPException(404, { message: 'Parameter change not found' }); + if (action !== 'read' && found.createdBy !== actor.id) + throw new HTTPException(403, { + message: 'Only the creator may change this proposal', + }); + const row = await expireParameter(tx, c, found); + if (row.status !== 'draft' || action === 'read') return row; + if (action === 'cancel') { + const [cancelled] = await tx + .update(modelParameterChange) + .set({ status: 'cancelled', resultAt: sql`clock_timestamp()` }) + .where(eq(modelParameterChange.changeId, changeId)) + .returning(); + await audit(tx, c, 'parameter.cancelled', 'model_parameter_change', changeId); + return cancelled!; + } + await tx.execute(sql`SELECT pg_advisory_xact_lock(8038, ${row.wellheadId})`); + const [well] = await tx + .select() + .from(wellhead) + .where( + and(eq(wellhead.wellheadId, row.wellheadId), eq(wellhead.status, 'active')) + ); + if (!well) throw new HTTPException(409, { message: 'Well is no longer active' }); + if ((await latestOverride(tx, row)) !== row.expectedOverrideId) + throw new HTTPException(409, { message: 'Parameter override changed' }); + await validateParameter(row); + const [override] = await tx + .insert(wellheadModelParameter) + .values({ + wellheadId: row.wellheadId, + modelVersion: row.modelVersion, + parameterKey: row.parameterKey, + value: row.value, + unit: row.unit, + source: 'authenticated_api', + changedBy: String(actor.id), + reason: row.reason, + validFrom: sql`clock_timestamp()`, + }) + .returning(); + if (!override) throw new Error('Override insert failed'); + const [published] = await tx + .update(modelParameterChange) + .set({ + status: 'published', + resultAt: sql`clock_timestamp()`, + publishedOverrideId: override.parameterOverrideId, + }) + .where(eq(modelParameterChange.changeId, changeId)) + .returning(); + await audit(tx, c, 'parameter.published', 'model_parameter_change', changeId); + return published!; + }); +} diff --git a/monorepo/apps/api/src/forecast-schedule-service.ts b/monorepo/apps/api/src/forecast-schedule-service.ts new file mode 100644 index 0000000..f4f8e11 --- /dev/null +++ b/monorepo/apps/api/src/forecast-schedule-service.ts @@ -0,0 +1,145 @@ +import { errorCode } from './utils/database-error'; +/** Schedule edits use the account-first lock order shared with the worker. */ +import { randomUUID } from 'node:crypto'; +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { and, db, desc, eq, sql } from '@well-simulation/db/query'; +import { + forecastSchedule, + forecastJob, +} from '@well-simulation/db/schemas/forecast-schedules'; +import { wellhead } from '@well-simulation/db/schemas/schema'; +import { auditEvent } from '@well-simulation/db/schemas/audit'; +import type { ForecastScheduleInput } from '@well-simulation/dto/req/forecast-schedule'; +import { + forecastScheduleViewSchema, + forecastScheduleSchema, +} from '@well-simulation/dto/res/forecast-schedule'; +import { currentActor } from './transaction-authority'; +import { currentRequestId } from './middleware/request-id'; + +export async function readForecastSchedule(wellheadId: number) { + const [asset] = await db + .select({ id: wellhead.wellheadId }) + .from(wellhead) + .where(eq(wellhead.wellheadId, wellheadId)); + if (!asset) throw new HTTPException(404, { message: 'Wellhead not found' }); + const [schedule, latest, completed] = await Promise.all([ + db + .select() + .from(forecastSchedule) + .where(eq(forecastSchedule.wellheadId, wellheadId)) + .limit(1), + db + .select() + .from(forecastJob) + .where(eq(forecastJob.wellheadId, wellheadId)) + .orderBy(desc(forecastJob.startedAt), desc(forecastJob.jobId)) + .limit(1), + db + .select() + .from(forecastJob) + .where( + and(eq(forecastJob.wellheadId, wellheadId), eq(forecastJob.status, 'complete')) + ) + .orderBy(desc(forecastJob.completedAt), desc(forecastJob.jobId)) + .limit(1), + ]); + return forecastScheduleViewSchema.parse({ + schedule: schedule[0] ?? null, + latestJob: latest[0] ?? null, + latestCompleted: completed[0] ?? null, + }); +} + +export async function saveForecastSchedule( + c: Context, + wellheadId: number, + input: ForecastScheduleInput +) { + try { + return await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`); + await tx.execute(sql`SET LOCAL statement_timeout = '3000ms'`); + const actor = await currentActor(tx, c, 'asset:manage'); + const [asset] = await tx + .select() + .from(wellhead) + .where(eq(wellhead.wellheadId, wellheadId)); + if (!asset) throw new HTTPException(404, { message: 'Wellhead not found' }); + if (input.enabled && asset.status !== 'active') + throw new HTTPException(409, { + message: 'Activate this well before enabling its schedule', + }); + const [current] = await tx + .select() + .from(forecastSchedule) + .where(eq(forecastSchedule.wellheadId, wellheadId)) + .for('update'); + if ((current?.revision ?? 0) !== input.expectedRevision) + throw new HTTPException(409, { + message: 'Schedule changed; read it again before saving', + }); + const values = { + ownerUserId: actor.id, + ownerRole: 'ADMIN', + enabled: input.enabled, + intervalSeconds: input.intervalMinutes * 60, + horizonSeconds: input.durationMinutes * 60, + stepSeconds: 60, + revision: input.expectedRevision + 1, + reason: input.reason, + updatedAt: sql`clock_timestamp()`, + nextDueAt: input.enabled + ? sql`clock_timestamp() + ${input.intervalMinutes} * interval '1 minute'` + : null, + }; + const [saved] = current + ? await tx + .update(forecastSchedule) + .set(values) + .where(eq(forecastSchedule.scheduleId, current.scheduleId)) + .returning() + : await tx + .insert(forecastSchedule) + .values({ ...values, scheduleId: randomUUID(), wellheadId }) + .returning(); + if (!saved) throw new Error('Schedule save returned no row'); + await tx.insert(auditEvent).values({ + occurredAt: sql`clock_timestamp()`, + actorUserId: actor.id, + action: 'forecast_schedule.updated', + subjectType: 'forecast_schedule', + subjectId: saved.scheduleId, + outcome: 'success', + requestId: currentRequestId(c), + detail: { + wellheadId, + revision: saved.revision, + enabled: saved.enabled, + intervalSeconds: saved.intervalSeconds, + horizonSeconds: saved.horizonSeconds, + reason: saved.reason, + }, + }); + return forecastScheduleSchema.parse(saved); + }); + } catch (error) { + if (error instanceof HTTPException) throw error; + if (errorCode(error) === '23505') + throw new HTTPException(409, { + message: 'Schedule changed; read it again before saving', + }); + console.error( + JSON.stringify({ + level: 'error', + service: 'api', + event: 'forecast_schedule_write_failed', + requestId: currentRequestId(c), + }) + ); + throw new HTTPException(503, { + message: 'Save outcome unavailable; read the schedule before retrying', + }); + } +} diff --git a/monorepo/apps/api/src/metrics.ts b/monorepo/apps/api/src/metrics.ts index 9295513..670348a 100644 --- a/monorepo/apps/api/src/metrics.ts +++ b/monorepo/apps/api/src/metrics.ts @@ -1,4 +1,10 @@ -import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client'; +import { + Counter, + Gauge, + Histogram, + Registry, + collectDefaultMetrics, +} from 'prom-client'; /** * Prometheus metrics for the API, per ADR 0029. @@ -11,6 +17,18 @@ export const registry = new Registry(); collectDefaultMetrics({ register: registry }); +export const databaseReady = new Gauge({ + name: 'well_sim_api_database_ready', + help: 'Result of the last completed database readiness probe', + registers: [registry], +}); + +export const readinessCheckedAt = new Gauge({ + name: 'well_sim_api_readiness_checked_timestamp_seconds', + help: 'UTC epoch time of the last completed readiness probe; zero before a probe', + registers: [registry], +}); + export const httpRequests = new Counter({ name: 'api_http_requests_total', help: 'HTTP requests handled, by method, route, and status', diff --git a/monorepo/apps/api/src/middleware/auth.ts b/monorepo/apps/api/src/middleware/auth.ts index 08eeeb1..7e9b063 100644 --- a/monorepo/apps/api/src/middleware/auth.ts +++ b/monorepo/apps/api/src/middleware/auth.ts @@ -2,6 +2,8 @@ import type { MiddlewareHandler } from 'hono'; import { deleteCookie, getCookie } from 'hono/cookie'; import { verify } from 'hono/jwt'; import { HTTPException } from 'hono/http-exception'; +import { db, eq } from '@well-simulation/db/query'; +import { users } from '@well-simulation/db/schemas/user'; import { config } from '../config'; @@ -14,6 +16,28 @@ import { config } from '../config'; */ const publicRoutes: string[] = ['/auth/login', '/metrics']; +/** + * Whether a correctly signed token is still current (ADR 0037). + * + * A signature only proves the API issued the token. Logout moves the user's + * session_version on, so a token issued before that, including a copied + * cookie, no longer matches. A token with no version predates this check and + * is refused. A deleted user has no row and is refused too. + */ +async function isCurrentSession(payload: Record): Promise { + const userId = Number.parseInt(String(payload.sub), 10); + if (!Number.isInteger(userId) || typeof payload.sv !== 'number') { + return false; + } + + const [row] = await db + .select({ sessionVersion: users.sessionVersion }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return row?.sessionVersion === payload.sv; +} + export const authMiddleware: MiddlewareHandler = async (c, next) => { const token = getCookie(c, 'auth_token'); @@ -33,16 +57,24 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => { } if (token) { + let payload: Record | undefined; try { - const payload = await verify(token, config.jwtSecret, config.jwtAlgorithm); - c.set('session', payload); + payload = await verify(token, config.jwtSecret, config.jwtAlgorithm); } catch { + payload = undefined; + } + + // A database error is not caught here. Refusing the request is safer than + // letting a session through without knowing whether it was revoked. + if (!payload || !(await isCurrentSession(payload))) { deleteCookie(c, 'auth_token'); if (isPublic) { return next(); } throw new HTTPException(401, { message: 'Unauthorized' }); } + + c.set('session', payload); } return next(); diff --git a/monorepo/apps/api/src/middleware/authorize.ts b/monorepo/apps/api/src/middleware/authorize.ts index 7fcbfd9..feaef01 100644 --- a/monorepo/apps/api/src/middleware/authorize.ts +++ b/monorepo/apps/api/src/middleware/authorize.ts @@ -12,9 +12,9 @@ import { * Capability checks for the model in ADR 0033. * * The session role is read from the verified JWT rather than the database, so - * a role change does not take effect until the token is reissued. That is - * acceptable for expanding access and deliberate for revoking it: session - * revocation is tracked separately in the remediation roadmap. + * a role change does not take effect until the token is reissued. Logout does + * end sessions on the server (ADR 0037): authMiddleware refuses any token whose + * session version is out of date before a capability is checked. */ type SessionClaims = { diff --git a/monorepo/apps/api/src/middleware/rate-limiter.ts b/monorepo/apps/api/src/middleware/rate-limiter.ts index 54f488d..0e8be76 100644 --- a/monorepo/apps/api/src/middleware/rate-limiter.ts +++ b/monorepo/apps/api/src/middleware/rate-limiter.ts @@ -21,8 +21,21 @@ type Bucket = { count: number; resetAt: number }; const WINDOW_MS = 60_000; const DEFAULT_LIMIT = 100; -/** Credential endpoints get a tighter budget than dashboard reads. */ +const PROJECTION_LIMIT = 10; +/** + * Routes that check a password or write an audit row get a tighter budget. + * + * Login and register are where a password can be guessed. Logout revokes all + * of the user's existing tokens (ADR 0037) and writes an audit row. This + * budget also limits how fast those audit rows can be written (ADR 0034). + * + * `/auth/me` is not listed. It was previously caught by an `/auth/` prefix + * match, so the dashboard's session check spent the same budget as login + * attempts and one open tab could lock its user out. It checks no password and + * writes nothing. + */ const AUTH_LIMIT = 10; +const CREDENTIAL_PATHS = new Set(['/auth/login', '/auth/register', '/auth/logout']); const SWEEP_INTERVAL_MS = 60_000; const buckets = new Map(); @@ -47,6 +60,32 @@ export function resetRateLimits(): void { buckets.clear(); } +/** One authenticated user receives ten command writes per minute across tabs. */ +export function controlWriteLimit(userId: number): MiddlewareHandler { + return async (c, next) => { + const key = `control-user:${userId}`; + const now = Date.now(); + let bucket = buckets.get(key); + if (!bucket || now >= bucket.resetAt) { + bucket = { count: 0, resetAt: now + WINDOW_MS }; + buckets.set(key, bucket); + } + bucket.count += 1; + if (bucket.count > 10) { + c.header('Retry-After', String(Math.ceil((bucket.resetAt - now) / 1000))); + return c.json( + { + error: 'Too many command requests', + msg: 'Too many command requests', + status: 429, + }, + 429 + ); + } + return next(); + }; +} + /** * Hosts permitted to speak for their clients via `x-forwarded-for`. * @@ -106,8 +145,18 @@ export function customRateLimit(props?: { return async (c, next) => { const pathname = new URL(c.req.url).pathname; - const effectiveLimit = pathname.startsWith('/auth/') ? AUTH_LIMIT : limit; - const key = `${clientKey(c)}:${pathname.startsWith('/auth/') ? 'auth' : 'api'}`; + const isCredential = CREDENTIAL_PATHS.has(pathname); + const isProjection = + c.req.method === 'POST' && + (pathname === '/twin/scenarios' || + /^\/twin\/wellheads\/[1-9][0-9]*\/forecast$/.test(pathname)); + const effectiveLimit = isCredential + ? AUTH_LIMIT + : isProjection + ? PROJECTION_LIMIT + : limit; + const category = isCredential ? 'auth' : isProjection ? 'projection' : 'api'; + const key = `${clientKey(c)}:${category}`; const now = Date.now(); let bucket = buckets.get(key); diff --git a/monorepo/apps/api/src/projection-details.ts b/monorepo/apps/api/src/projection-details.ts new file mode 100644 index 0000000..e61c5e9 --- /dev/null +++ b/monorepo/apps/api/src/projection-details.ts @@ -0,0 +1,98 @@ +import { storedExecutionOrigin } from '@well-simulation/dto/res/forecast-schedule'; +/** Saved-run presentation uses stored inputs and the canonical signal catalogue. */ +import { HTTPException } from 'hono/http-exception'; +import { db, desc, eq, sql } from '@well-simulation/db/query'; +import { + parametertype, + predictionRun, + scenarioRun, + wellhead, +} from '@well-simulation/db/schemas/schema'; +import { + projectionDetailSchema, + runHistoryQuerySchema, + storedScheduledControlSchema, + type RunHistory, +} from '@well-simulation/dto/res/projection-detail'; +import { storedBranchOrigin } from '@well-simulation/dto/res/snapshots'; +import { SCENARIO_CONTROLS } from '@well-simulation/dto/req/scenario-controls'; +import type { ProjectionResponse } from '@well-simulation/dto/res/projection'; + +export async function projectionDetail( + result: ProjectionResponse, + run: { + controlValues: Record; + scheduledControls?: Record[]; + } +) { + const signals = await db + .select({ + code: parametertype.code, + displayName: parametertype.displayName, + canonicalUnit: parametertype.canonicalUnit, + dataType: parametertype.dataType, + }) + .from(parametertype) + .orderBy(parametertype.parameterTypeId); + const keys = new Set(result.points.flatMap((point) => Object.keys(point.values))); + return projectionDetailSchema.parse({ + ...result, + baselineControls: run.controlValues, + scheduledControls: (run.scheduledControls ?? []).map((raw) => { + const step = storedScheduledControlSchema.parse(raw); + return { + offsetMinutes: step.offset_seconds / 60, + input: Object.fromEntries( + Object.entries(SCENARIO_CONTROLS).flatMap(([key, definition]) => { + const value = step.change[definition.storedKey]; + return value === null || value === undefined ? [] : [[key, value]]; + }) + ), + }; + }), + signals: signals.filter((signal) => keys.has(signal.code)), + }); +} + +/** Bound the query and select summaries only, never thousands of point rows. */ +export async function runHistory( + wellheadId: number, + query: Record +): Promise { + const { kind, limit, offset } = runHistoryQuerySchema.parse(query); + const [asset] = await db + .select({ id: wellhead.wellheadId }) + .from(wellhead) + .where(eq(wellhead.wellheadId, wellheadId)) + .limit(1); + if (!asset) throw new HTTPException(404, { message: 'Wellhead not found' }); + const table = kind === 'forecast' ? predictionRun : scenarioRun; + const rows = await db + .select({ + runId: table.runId, + wellheadId: table.wellheadId, + name: kind === 'scenario' ? scenarioRun.name : sql`NULL`, + createdAt: table.createdAt, + baseSimulatedAt: table.baseSimulatedAt, + modelVersion: table.modelVersion, + horizonSeconds: table.horizonSeconds, + stepSeconds: table.stepSeconds, + confidenceScore: table.confidenceScore, + execution: sql`coalesce(${table.baseState}->'executionOrigin', '{"kind":"manual"}'::jsonb)`, + origin: sql`coalesce(${table.baseState}->'branchOrigin', '{"kind":"current"}'::jsonb)`, + }) + .from(table) + .where(eq(table.wellheadId, wellheadId)) + .orderBy(desc(table.createdAt), desc(table.runId)) + .limit(limit + 1) + .offset(offset); + return { + items: rows.slice(0, limit).map(({ origin, execution, ...row }) => ({ + ...row, + kind, + branchOrigin: storedBranchOrigin({ branchOrigin: origin }), + executionOrigin: storedExecutionOrigin({ executionOrigin: execution }), + })), + hasMore: rows.length > limit, + }; +} diff --git a/monorepo/apps/api/src/routers/alarms.ts b/monorepo/apps/api/src/routers/alarms.ts new file mode 100644 index 0000000..80f9db6 --- /dev/null +++ b/monorepo/apps/api/src/routers/alarms.ts @@ -0,0 +1,79 @@ +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { validator } from 'hono/validator'; +import { + alarmActionInputSchema, + alarmIdentitySchema, + alarmQuerySchema, + alarmRequestKeySchema, + alarmDetailQuerySchema, + shelfQuerySchema, +} from '@well-simulation/dto/req/alarms'; +import { requireCapability, sessionUserId } from '../middleware/authorize'; +import { controlWriteLimit } from '../middleware/rate-limiter'; +import { alarmDetail, listAlarms, listShelves, ownAlarmReceipt } from '../alarm-read'; +import { applyAlarmAction } from '../alarm-write'; + +export const alarmsRouter = new Hono() + .use('*', bodyLimit({ maxSize: 4096 })) + .get( + '/', + requireCapability('dashboard:read'), + validator('query', (value) => alarmQuerySchema.parse(value)), + async (c) => + c.json({ + data: await listAlarms(c.req.valid('query')), + msg: 'Alarm state loaded', + status: 200 as const, + }) + ) + .get( + '/shelves', + requireCapability('dashboard:read'), + validator('query', (value) => shelfQuerySchema.parse(value)), + async (c) => + c.json({ + data: await listShelves(c.req.valid('query')), + msg: 'Current shelves loaded', + status: 200 as const, + }) + ) + .get( + '/events/:eventId', + requireCapability('dashboard:read'), + validator('query', (value) => alarmDetailQuerySchema.parse(value)), + async (c) => { + const eventId = alarmIdentitySchema.shape.eventId.parse(c.req.param('eventId')); + return c.json({ + data: await alarmDetail(eventId, c.req.valid('query').triggeredAt), + msg: 'Event and recent actions loaded', + status: 200 as const, + }); + } + ) + .get('/actions/:requestKey', requireCapability('dashboard:read'), async (c) => + c.json({ + data: await ownAlarmReceipt( + sessionUserId(c), + alarmRequestKeySchema.parse(c.req.param('requestKey')) + ), + msg: 'Your committed action', + status: 200 as const, + }) + ) + .post( + '/actions', + requireCapability('alarm:acknowledge'), + (c, next) => controlWriteLimit(sessionUserId(c))(c, next), + validator('json', (value) => alarmActionInputSchema.parse(value)), + async (c) => + c.json({ + data: await applyAlarmAction( + c, + c.req.valid('json'), + alarmRequestKeySchema.parse(c.req.header('Idempotency-Key')) + ), + msg: 'Alarm action recorded', + status: 200 as const, + }) + ); diff --git a/monorepo/apps/api/src/routers/auth.ts b/monorepo/apps/api/src/routers/auth.ts index 97d03be..f4417a1 100644 --- a/monorepo/apps/api/src/routers/auth.ts +++ b/monorepo/apps/api/src/routers/auth.ts @@ -1,5 +1,5 @@ import { Hono, type Context } from 'hono'; -import { db } from '@well-simulation/db/query'; +import { db, eq, sql } from '@well-simulation/db/query'; import { users } from '@well-simulation/db/schemas/user'; import { zValidator } from '@hono/zod-validator'; import { loginSchema, registerSchema } from '@well-simulation/dto/req/auth'; @@ -20,9 +20,14 @@ const BCRYPT_ROUNDS = 11; type UserRecord = typeof users.$inferSelect; function publicUser(user: UserRecord) { - // Destructured purely to drop the hash from the response. The underscore - // marks it as deliberately unused; removing it would leak the password hash. - const { encryptedPassword: _encryptedPassword, ...safeUser } = user; + // Destructured purely to drop fields from the response. The underscores mark + // them as deliberately unused; removing them would leak the password hash and + // the internal session counter. + const { + encryptedPassword: _encryptedPassword, + sessionVersion: _sessionVersion, + ...safeUser + } = user; return safeUser; } @@ -33,6 +38,8 @@ async function issueSession(c: Context, user: UserRecord) { sub: String(user.id), email: user.email, role: user.role ?? DEFAULT_ROLE, + // Checked on every request against users.session_version (ADR 0037). + sv: user.sessionVersion, exp: expiresAtSeconds, }, config.jwtSecret, @@ -171,6 +178,14 @@ export const authRouter = new Hono() // actor rather than being anonymous. const userId = sessionUserId(c); + // Clearing the cookie only affects this browser. Moving the version on + // makes every token issued so far fail the check in authMiddleware, so a + // copied cookie stops working too (ADR 0037). + await db + .update(users) + .set({ sessionVersion: sql`${users.sessionVersion} + 1` }) + .where(eq(users.id, userId)); + deleteCookie(c, AUTH_COOKIE_NAME, { path: '/' }); await recordAuditEvent(c, { diff --git a/monorepo/apps/api/src/routers/controls.ts b/monorepo/apps/api/src/routers/controls.ts new file mode 100644 index 0000000..f3e7469 --- /dev/null +++ b/monorepo/apps/api/src/routers/controls.ts @@ -0,0 +1,197 @@ +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { validator } from 'hono/validator'; +import { + commandIdSchema, + confirmationSchema, + controlActivateSchema, + controlDraftSchema, + parameterDraftSchema, + wellIdSchema, + proposalHistoryQuerySchema, +} from '@well-simulation/dto/req/controls'; +import { requireCapability, sessionUserId } from '../middleware/authorize'; +import { controlWriteLimit } from '../middleware/rate-limiter'; +import { + changeCommand, + changeParameter, + commandStatus, + controlStatus, + draftCommand, + draftParameter, + parameterStatus, + proposalHistory, +} from '../control-service'; +import { notifyTwin, runningControls } from '../twin-control-client'; + +const uuid = commandIdSchema; + +/** Human command routes never accept a service token from the browser. */ +export const controlsRouter = new Hono() + .use('*', bodyLimit({ maxSize: 4096 })) + .use('*', async (c, next) => { + if (c.req.method === 'POST') return controlWriteLimit(sessionUserId(c))(c, next); + return next(); + }) + .get( + '/wellheads/:wellheadId/controls', + requireCapability('dashboard:read'), + async (c) => { + const data = await controlStatus( + wellIdSchema.parse(Number(c.req.param('wellheadId'))) + ); + return c.json({ data, msg: 'Control state loaded', status: 200 as const }); + } + ) + .get( + '/wellheads/:wellheadId/control-commands', + requireCapability('control:issue'), + validator('query', (value) => proposalHistoryQuerySchema.parse(value)), + async (c) => { + const data = await proposalHistory( + c, + wellIdSchema.parse(Number(c.req.param('wellheadId'))), + 'control', + c.req.valid('query') + ); + return c.json({ data, msg: 'Your control proposals', status: 200 as const }); + } + ) + .get( + '/wellheads/:wellheadId/parameter-changes', + requireCapability('asset:manage'), + validator('query', (value) => proposalHistoryQuerySchema.parse(value)), + async (c) => { + const data = await proposalHistory( + c, + wellIdSchema.parse(Number(c.req.param('wellheadId'))), + 'parameter', + c.req.valid('query') + ); + return c.json({ data, msg: 'Your parameter proposals', status: 200 as const }); + } + ) + .post( + '/control-commands', + requireCapability('control:issue'), + validator('json', (value) => controlDraftSchema.parse(value)), + async (c) => { + const data = await draftCommand( + c, + c.req.valid('json'), + uuid.parse(c.req.header('Idempotency-Key')) + ); + return c.json( + { data, msg: 'Control proposal recorded', status: 201 as const }, + 201 + ); + } + ) + .post( + '/control-commands/:id/activate', + requireCapability('control:issue'), + validator('json', (value) => controlActivateSchema.parse(value)), + async (c) => { + const input = c.req.valid('json'); + const data = await changeCommand( + c, + uuid.parse(c.req.param('id')), + 'activate', + input.expectedRevision + ); + const notified = + data.status === 'queued' + ? await notifyTwin(`/control-commands/${data.commandId}/notify`) + : true; + const status = data.status === 'queued' ? 202 : 200; + return c.json( + { + data: { ...data, notificationPending: !notified }, + msg: 'Control command status', + status: status as 200 | 202, + }, + status + ); + } + ) + .post( + '/control-commands/:id/cancel', + requireCapability('control:issue'), + validator('json', (value) => confirmationSchema.parse(value)), + async (c) => { + const data = await changeCommand(c, uuid.parse(c.req.param('id')), 'cancel'); + return c.json({ data, msg: 'Control command status', status: 200 as const }); + } + ) + .get('/control-commands/:id', requireCapability('control:issue'), async (c) => { + const data = await commandStatus(c, uuid.parse(c.req.param('id'))); + return c.json({ data, msg: 'Control command status', status: 200 as const }); + }) + .post( + '/parameter-changes', + requireCapability('asset:manage'), + validator('json', (value) => parameterDraftSchema.parse(value)), + async (c) => { + const data = await draftParameter( + c, + c.req.valid('json'), + uuid.parse(c.req.header('Idempotency-Key')) + ); + return c.json( + { data, msg: 'Parameter proposal recorded', status: 201 as const }, + 201 + ); + } + ) + .get( + '/wellheads/:wellheadId/parameters', + requireCapability('asset:manage'), + async (c) => { + const data = await parameterStatus( + c, + wellIdSchema.parse(Number(c.req.param('wellheadId'))) + ); + return c.json({ + data, + msg: 'Published and running parameters', + status: 200 as const, + }); + } + ) + .post( + '/parameter-changes/:id/publish', + requireCapability('asset:manage'), + validator('json', (value) => confirmationSchema.parse(value)), + async (c) => { + const data = await changeParameter(c, uuid.parse(c.req.param('id')), 'publish'); + const notified = + data.status === 'published' ? await notifyTwin('/fleet/refresh') : true; + return c.json({ + data: { + ...data, + restartRequired: data.status === 'published', + notificationPending: !notified, + }, + msg: 'Parameter proposal status', + status: 200 as const, + }); + } + ) + .post( + '/parameter-changes/:id/cancel', + requireCapability('asset:manage'), + validator('json', (value) => confirmationSchema.parse(value)), + async (c) => { + const data = await changeParameter(c, uuid.parse(c.req.param('id')), 'cancel'); + return c.json({ data, msg: 'Parameter proposal status', status: 200 as const }); + } + ) + .get('/parameter-changes/:id', requireCapability('asset:manage'), async (c) => { + const data = await changeParameter(c, uuid.parse(c.req.param('id')), 'read'); + const running = await runningControls(data.wellheadId).catch(() => null); + return c.json({ + data: { ...data, running }, + msg: 'Parameter proposal status', + status: 200 as const, + }); + }); diff --git a/monorepo/apps/api/src/routers/dashboard.ts b/monorepo/apps/api/src/routers/dashboard.ts index 04f81c3..8c9cfbd 100644 --- a/monorepo/apps/api/src/routers/dashboard.ts +++ b/monorepo/apps/api/src/routers/dashboard.ts @@ -7,10 +7,15 @@ import { parametertype, wellhead, } from '@well-simulation/db/schemas/schema'; -import type { - DashboardAnalyticsResponse, - DashboardOverviewResponse, - WellheadDetailResponse, +import { + ALARM_SEVERITIES, + type ActiveAlarmTotals, + type AlarmCount, + type ActiveAlarmsResponse, + type AlarmSeverity, + type DashboardAnalyticsResponse, + type DashboardOverviewResponse, + type WellheadDetailResponse, } from '@well-simulation/dto/res/dashboard'; import { mv5minAlarmCounts, @@ -26,7 +31,11 @@ import { HTTPException } from 'hono/http-exception'; const RECENT_READING_LIMIT = 24; const ACTIVE_ALARM_LIMIT = 12; +/** Rows in the Alarm Center list. The totals always cover every alarm. */ +const ALARM_CENTER_LIMIT = 50; const ANALYTICS_TREND_LIMIT = 144; +/** Hours of alarm history the Analytics severity total covers. */ +const ALARM_COUNT_WINDOW_HOURS = 24; async function countRows( table: typeof wellhead | typeof parametertype | typeof vActiveAlarms @@ -135,6 +144,46 @@ async function activeAlarms(limit = ACTIVE_ALARM_LIMIT) { .limit(limit); } +/** + * Position of an alarm's severity in ALARM_SEVERITIES, or the list length for + * a level the list does not name, so unknown levels sort last. + */ +const severityRank = sql`case ${vActiveAlarms.severityLevel} ${sql.join( + ALARM_SEVERITIES.map((level, rank) => sql`when ${level} then ${rank}`), + sql` ` +)} else ${ALARM_SEVERITIES.length} end`; + +/** + * The Alarm Center's list: most severe first, then oldest first. + * + * The list is capped, so the cut must follow the order the operator works in. + * Sorting newest first and cutting there would drop an old critical alarm + * while showing a recent warning. + */ +async function rankedActiveAlarms(limit: number) { + return db + .select() + .from(vActiveAlarms) + .orderBy(severityRank, asc(vActiveAlarms.triggeredAt), asc(vActiveAlarms.eventId)) + .limit(limit); +} + +/** Counts over every active alarm, independent of the list's cap. */ +async function activeAlarmTotals(): Promise { + const critical: AlarmSeverity = 'CRITICAL'; + const warning: AlarmSeverity = 'WARNING'; + const [row] = await db + .select({ + total: sql`count(*)::int`, + critical: sql`(count(*) filter (where ${vActiveAlarms.severityLevel} = ${critical}))::int`, + warning: sql`(count(*) filter (where ${vActiveAlarms.severityLevel} = ${warning}))::int`, + affectedWellheads: sql`count(distinct ${vActiveAlarms.wellheadId})::int`, + }) + .from(vActiveAlarms); + + return row ?? { total: 0, critical: 0, warning: 0, affectedWellheads: 0 }; +} + async function activeAlarmsForWellhead(wellheadId: number) { return db .select() @@ -267,12 +316,28 @@ async function waterCutGorTrendForWellhead(wellheadId: number) { .limit(ANALYTICS_TREND_LIMIT); } -async function alarmCounts() { +/** + * Alarms triggered in the last ALARM_COUNT_WINDOW_HOURS, one total per severity. + * + * This previously returned the newest 144 rows of the aggregate, where a row is + * one bucket for one wellhead, parameter, and severity. The period that covered + * changed with the alarm mix, and on a stack that had been stopped it reached + * back days, so the Analytics total described no stated time. The window is now + * a time bound, and the totals are summed here because the page needs nothing + * finer. + */ +async function alarmCounts(): Promise { return db - .select() + .select({ + severityLevel: mv5minAlarmCounts.severityLevel, + alarmsTriggered: sql`coalesce(sum(${mv5minAlarmCounts.alarmsTriggered}), 0)::int`, + }) .from(mv5minAlarmCounts) - .orderBy(desc(mv5minAlarmCounts.bucketTime)) - .limit(ANALYTICS_TREND_LIMIT); + .where( + sql`${mv5minAlarmCounts.bucketTime} > now() - make_interval(hours => ${ALARM_COUNT_WINDOW_HOURS})` + ) + .groupBy(mv5minAlarmCounts.severityLevel) + .orderBy(mv5minAlarmCounts.severityLevel); } export const dashboardRouter = new Hono() @@ -289,9 +354,25 @@ export const dashboardRouter = new Hono() latestReadingTimestamp(), ]); - const [readings, alarms] = await Promise.all([ + const [readings, alarms, assets] = await Promise.all([ latestSnapshotReadings(latestAt), activeAlarms(), + db + .select({ + wellheadId: wellhead.wellheadId, + wellheadName: wellhead.name, + activeAlarmCount: sql`(select count(*)::int from v_active_alarms where wellhead_id = ${wellhead.wellheadId})`, + wellheadType: wellhead.type, + status: wellhead.status, + locationId: location.locationId, + locationName: location.name, + fieldId: field.fieldId, + fieldName: field.name, + }) + .from(wellhead) + .innerJoin(location, eq(wellhead.locationId, location.locationId)) + .innerJoin(field, eq(location.fieldId, field.fieldId)) + .orderBy(wellhead.wellheadId), ]); const overview: DashboardOverviewResponse = { @@ -304,6 +385,7 @@ export const dashboardRouter = new Hono() }, latestReadings: readings, activeAlarms: alarms, + wellheads: assets, }; return overview; @@ -316,7 +398,12 @@ export const dashboardRouter = new Hono() ) .get('/active-alarms', (c) => responseHandler(async () => { - return { alarms: await activeAlarms(50) }; + const [totals, alarms] = await Promise.all([ + activeAlarmTotals(), + rankedActiveAlarms(ALARM_CENTER_LIMIT), + ]); + const response: ActiveAlarmsResponse = { totals, alarms }; + return response; })(c) ) .get('/wellheads/:wellheadId', (c) => @@ -345,6 +432,7 @@ export const dashboardRouter = new Hono() const detail: WellheadDetailResponse = { wellhead: asset, latestReadingAt: latestAt, + latestReadingAgeSeconds: ageInSeconds(latestAt), latestReadings: readings, activeAlarms: alarms, pressureTrend: pressure, diff --git a/monorepo/apps/api/src/routers/twin.ts b/monorepo/apps/api/src/routers/twin.ts new file mode 100644 index 0000000..a583d69 --- /dev/null +++ b/monorepo/apps/api/src/routers/twin.ts @@ -0,0 +1,354 @@ +import { forecastScheduleInputSchema } from '@well-simulation/dto/req/forecast-schedule'; +import { storedExecutionOrigin } from '@well-simulation/dto/res/forecast-schedule'; +import { + readForecastSchedule, + saveForecastSchedule, +} from '../forecast-schedule-service'; +import { Hono } from 'hono'; +import { validator } from 'hono/validator'; +import { HTTPException } from 'hono/http-exception'; +import { bodyLimit } from 'hono/body-limit'; +import { asc, db, eq } from '@well-simulation/db/query'; +import { + predictionPoint, + predictionRun, + scenarioPoint, + scenarioRun, +} from '@well-simulation/db/schemas/schema'; +import { + forecastRequestSchema, + scenarioRequestSchema, +} from '@well-simulation/dto/req/projection'; +import { + projectionResponseSchema, + type ProjectionResponse, +} from '@well-simulation/dto/res/projection'; + +import { recordAuditEvent } from '../audit'; +import { config } from '../config'; +import { requireCapability, sessionUserId } from '../middleware/authorize'; +import { responseHandler } from '../utils/handler'; + +import { projectionDetail, runHistory } from '../projection-details'; +import { modelStatus, snapshotCandidates } from '../twin-control-client'; +import { + snapshotSearchSchema, + snapshotSelectionSchema, + snapshotTimeKey, +} from '@well-simulation/dto/req/snapshots'; +import { storedBranchOrigin } from '@well-simulation/dto/res/snapshots'; + +const MAX_TWIN_RESPONSE_BYTES = 5_000_000; +const TWIN_TIMEOUT_MS = 12_000; +const RUN_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function positiveWellheadId(raw: string | undefined): number { + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0 || value > 2_147_483_647) { + throw new HTTPException(400, { message: 'Invalid wellhead id' }); + } + return value; +} + +function runId(raw: string | undefined): string { + if (!raw || !RUN_ID_PATTERN.test(raw)) { + throw new HTTPException(400, { message: 'Invalid run id' }); + } + return raw; +} + +/** The browser never receives the service token or calls twin-core directly. */ +async function runTwin( + path: string, + actor: number, + body: object +): Promise { + let response: Response; + try { + response = await fetch(`${config.twinCoreUrl}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-twin-internal-token': config.twinInternalToken, + 'x-twin-actor-id': String(actor), + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TWIN_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + throw new HTTPException(504, { message: 'Twin service timed out' }); + } + throw new HTTPException(503, { message: 'Twin service unavailable' }); + } + if ([400, 404, 409, 503].includes(response.status)) { + throw new HTTPException(response.status as 400 | 404 | 409 | 503, { + message: + response.status === 409 + ? 'The selected snapshot no longer matches this model' + : 'Twin request could not complete', + }); + } + if (response.status !== 201) { + throw new HTTPException(502, { message: 'Twin service rejected the request' }); + } + let text: string; + try { + text = await response.text(); + } catch { + throw new HTTPException(503, { message: 'Twin service response unavailable' }); + } + if (text.length > MAX_TWIN_RESPONSE_BYTES) { + throw new HTTPException(502, { message: 'Twin service response is too large' }); + } + try { + const result = projectionResponseSchema.parse(JSON.parse(text)); + if ('baseSnapshot' in body && body.baseSnapshot !== undefined) { + const selected = snapshotSelectionSchema.parse(body.baseSnapshot); + if ( + result.kind !== 'scenario' || + result.branchOrigin.kind !== 'snapshot' || + result.branchOrigin.snapshotId !== selected.snapshotId || + snapshotTimeKey(result.branchOrigin.simulatedAt) !== + snapshotTimeKey(selected.simulatedAt) || + result.baseSimulatedAt !== result.branchOrigin.simulatedAt + ) + throw new Error('Historical source mismatch'); + } else if (result.branchOrigin.kind !== 'current') { + throw new Error('Current source mismatch'); + } + return result; + } catch { + throw new HTTPException(502, { message: 'Twin service response is invalid' }); + } +} + +function baseResponse(run: { + runId: string; + wellheadId: number; + modelVersion: string; + baseSimulatedAt: string; + createdAt: string; + horizonSeconds: number; + stepSeconds: number; + confidenceScore: number; + confidenceReasons: string[]; + baseState: Record; +}) { + return { + runId: run.runId, + wellheadId: run.wellheadId, + source: 'synthetic_reduced_order_model' as const, + fieldCalibrated: false as const, + modelVersion: run.modelVersion, + baseSimulatedAt: run.baseSimulatedAt, + generatedAt: run.createdAt, + branchOrigin: storedBranchOrigin(run.baseState), + executionOrigin: storedExecutionOrigin(run.baseState), + horizonSeconds: run.horizonSeconds, + stepSeconds: run.stepSeconds, + confidence: { + method: 'heuristic_uncalibrated' as const, + score: run.confidenceScore, + label: run.confidenceScore >= 0.4 ? ('moderate' as const) : ('low' as const), + reasons: run.confidenceReasons, + }, + }; +} + +async function savedForecast(runId: string) { + const [run] = await db + .select() + .from(predictionRun) + .where(eq(predictionRun.runId, runId)) + .limit(1); + if (!run) throw new HTTPException(404, { message: 'Forecast not found' }); + const rows = await db + .select() + .from(predictionPoint) + .where(eq(predictionPoint.runId, runId)) + .orderBy(asc(predictionPoint.forecastAt)); + if (rows.length !== run.horizonSeconds / run.stepSeconds) + throw new HTTPException(410, { message: 'Forecast points have expired' }); + return projectionDetail( + projectionResponseSchema.parse({ + ...baseResponse(run), + kind: 'forecast', + name: null, + points: rows.map((point) => ({ + elapsedSeconds: point.elapsedSeconds, + timestamp: point.forecastAt, + values: point.values, + alarmRisk: point.alarmRisk, + triggeredRuleIds: point.triggeredRuleIds, + })), + }), + run + ); +} + +async function savedScenario(runId: string) { + const [run] = await db + .select() + .from(scenarioRun) + .where(eq(scenarioRun.runId, runId)) + .limit(1); + if (!run) throw new HTTPException(404, { message: 'Scenario not found' }); + const rows = await db + .select() + .from(scenarioPoint) + .where(eq(scenarioPoint.runId, runId)) + .orderBy(asc(scenarioPoint.forecastAt)); + if (rows.length !== run.horizonSeconds / run.stepSeconds) + throw new HTTPException(410, { message: 'Scenario points have expired' }); + return projectionDetail( + projectionResponseSchema.parse({ + ...baseResponse(run), + kind: 'scenario', + name: run.name, + points: rows.map((point) => ({ + elapsedSeconds: point.elapsedSeconds, + timestamp: point.forecastAt, + values: point.values, + alarmRisk: point.alarmRisk, + triggeredRuleIds: point.triggeredRuleIds, + baselineValues: point.baselineValues, + baselineAlarmRisk: point.baselineAlarmRisk, + })), + }), + run + ); +} + +/** Public analysis routes. They cannot apply a live actuator command. */ +export const twinRouter = new Hono() + .use('*', bodyLimit({ maxSize: 4096 })) + .get( + '/wellheads/:wellheadId/forecast-schedule', + requireCapability('dashboard:read'), + responseHandler((c) => + readForecastSchedule(positiveWellheadId(c.req.param('wellheadId'))) + ) + ) + .put( + '/wellheads/:wellheadId/forecast-schedule', + requireCapability('asset:manage'), + validator('json', (value) => forecastScheduleInputSchema.parse(value)), + (c) => + responseHandler( + () => + saveForecastSchedule( + c, + positiveWellheadId(c.req.param('wellheadId')), + c.req.valid('json') + ), + 'Schedule saved' + )(c) + ) + .get('/status', requireCapability('dashboard:read'), responseHandler(modelStatus)) + .get( + '/wellheads/:wellheadId/snapshots', + requireCapability('scenario:run'), + validator('query', (value) => snapshotSearchSchema.parse(value)), + (c) => + responseHandler(() => + snapshotCandidates( + positiveWellheadId(c.req.param('wellheadId')), + c.req.valid('query').at + ) + )(c) + ) + .get( + '/wellheads/:wellheadId/runs', + requireCapability('dashboard:read'), + validator('query', (value) => value), + (c) => + responseHandler(() => + runHistory(positiveWellheadId(c.req.param('wellheadId')), c.req.query()) + )(c) + ) + .post( + '/wellheads/:wellheadId/forecast', + requireCapability('scenario:run'), + validator('json', (value) => forecastRequestSchema.parse(value)), + (c) => + responseHandler(async () => { + const actor = sessionUserId(c); + const wellheadId = positiveWellheadId(c.req.param('wellheadId')); + const input = c.req.valid('json'); + try { + const result = await runTwin( + `/wellheads/${wellheadId}/forecast`, + actor, + input + ); + await recordAuditEvent(c, { + action: 'twin.forecast', + outcome: 'success', + actorUserId: actor, + subjectType: 'prediction_run', + subjectId: result.runId, + detail: { wellheadId, horizonSeconds: input.horizonSeconds }, + }); + return result; + } catch (error) { + await recordAuditEvent(c, { + action: 'twin.forecast', + outcome: 'failure', + actorUserId: actor, + detail: { wellheadId }, + }); + throw error; + } + }, 'Forecast generated')(c) + ) + .post( + '/scenarios', + requireCapability('scenario:run'), + validator('json', (value) => scenarioRequestSchema.parse(value)), + (c) => + responseHandler(async () => { + const actor = sessionUserId(c); + const input = c.req.valid('json'); + try { + const result = await runTwin('/scenarios', actor, input); + await recordAuditEvent(c, { + action: 'twin.scenario', + outcome: 'success', + actorUserId: actor, + subjectType: 'scenario_run', + subjectId: result.runId, + detail: { + wellheadId: input.wellheadId, + horizonSeconds: input.horizonSeconds, + }, + }); + return result; + } catch (error) { + await recordAuditEvent(c, { + action: 'twin.scenario', + outcome: 'failure', + actorUserId: actor, + detail: { wellheadId: input.wellheadId }, + }); + throw error; + } + }, 'Scenario generated')(c) + ) + .get( + '/forecasts/:runId', + requireCapability('dashboard:read'), + responseHandler( + async (c) => savedForecast(runId(c.req.param('runId'))), + 'Forecast loaded' + ) + ) + .get( + '/scenarios/:runId', + requireCapability('dashboard:read'), + responseHandler( + async (c) => savedScenario(runId(c.req.param('runId'))), + 'Scenario loaded' + ) + ); diff --git a/monorepo/apps/api/src/transaction-authority.ts b/monorepo/apps/api/src/transaction-authority.ts new file mode 100644 index 0000000..a457481 --- /dev/null +++ b/monorepo/apps/api/src/transaction-authority.ts @@ -0,0 +1,32 @@ +import type { Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { db, eq } from '@well-simulation/db/query'; +import { users } from '@well-simulation/db/schemas/user'; +import { + hasCapability, + isRole, + type Capability, +} from '@well-simulation/dto/auth/roles'; +import { sessionUserId } from './middleware/authorize'; +export type Transaction = Parameters[0]>[0]; + +/** Lock the current account, not the potentially stale role in its JWT. */ +export async function currentActor( + tx: Transaction, + c: Context, + capability: Capability +) { + const id = sessionUserId(c); + const [actor] = await tx.select().from(users).where(eq(users.id, id)).for('update'); + const claims = c.get('session') as Record; + if (!actor || actor.sessionVersion !== claims.sv) + throw new HTTPException(401, { message: 'Session revoked' }); + if ( + actor.status !== 'ACTIVE' || + actor.deleted === true || + !isRole(actor.role) || + !hasCapability(actor.role, capability) + ) + throw new HTTPException(403, { message: 'Forbidden' }); + return actor; +} diff --git a/monorepo/apps/api/src/twin-control-client.ts b/monorepo/apps/api/src/twin-control-client.ts new file mode 100644 index 0000000..43368e3 --- /dev/null +++ b/monorepo/apps/api/src/twin-control-client.ts @@ -0,0 +1,132 @@ +/** Internal HTTP contracts for control status and parameter validation. */ +import { HTTPException } from 'hono/http-exception'; +import type { ParameterDraft } from '@well-simulation/dto/req/controls'; +import { + runningControlsSchema, + parameterValidationSchema, + parameterRuntimeSchema, + parameterCatalogueSchema, +} from '@well-simulation/dto/res/controls'; +import { config } from './config'; +import { snapshotListSchema } from '@well-simulation/dto/res/snapshots'; +import { + fleetModelStatusSchema, + type FleetModelStatus, +} from '@well-simulation/dto/res/model-status'; + +/** Bounded service calls carry only server-owned credentials. */ +async function twinRequest( + path: string, + body?: object, + maxBytes = 16_384 +): Promise { + try { + const response = await fetch(`${config.twinCoreUrl}${path}`, { + method: body ? 'POST' : 'GET', + headers: { + 'content-type': 'application/json', + 'x-twin-internal-token': config.twinInternalToken, + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(2000), + }); + if (response.status === 400) + throw new HTTPException(400, { message: 'Model validation failed' }); + if (response.status === 404) + throw new HTTPException(404, { message: 'Well not found' }); + if (!response.ok) throw new Error('Twin not ready'); + const text = await response.text(); + if (text.length > maxBytes) throw new Error('Twin response too large'); + return JSON.parse(text) as unknown; + } catch (error) { + if (error instanceof HTTPException) throw error; + throw new HTTPException(503, { message: 'Twin service unavailable' }); + } +} + +/** A failed hint never rewrites a successfully committed command outcome. */ +export async function notifyTwin(path: string): Promise { + try { + await twinRequest(path, {}); + return true; + } catch { + return false; + } +} + +export async function runningControls(wellheadId: number) { + const result = runningControlsSchema.safeParse( + await twinRequest(`/wellheads/${wellheadId}/controls`) + ); + if (!result.success || result.data.wellheadId !== wellheadId) + throw new HTTPException(503, { message: 'Running controls unavailable' }); + return result.data; +} + +/** Load lifecycle identity even if invalid parameters prevented model startup. */ +export async function parameterIdentity(wellheadId: number) { + const identity = parameterRuntimeSchema.safeParse( + await twinRequest(`/wellheads/${wellheadId}/state`) + ); + if (!identity.success || identity.data.wellheadId !== wellheadId) + throw new HTTPException(503, { message: 'Model identity unavailable' }); + return identity.data; +} + +export async function parameterCatalogue(wellheadId: number) { + const result = parameterCatalogueSchema.safeParse( + await twinRequest(`/wellheads/${wellheadId}/parameters`, undefined, 128_000) + ); + if (!result.success || result.data.wellheadId !== wellheadId) + throw new HTTPException(503, { message: 'Parameter definitions unavailable' }); + return result.data; +} + +/** Ask the authoritative Python resolver to validate the complete parameter set. */ +export async function validateParameter(input: ParameterDraft) { + const { wellheadId, modelVersion, parameterKey, value, unit } = input; + const result = parameterValidationSchema.safeParse( + await twinRequest('/parameter-changes/validate', { + wellheadId, + modelVersion, + parameterKey, + value, + unit, + }) + ); + if (!result.success || result.data.modelVersion !== input.modelVersion) + throw new HTTPException(503, { message: 'Parameter validation unavailable' }); + return result.data; +} + +/** An optional model failure must not hide available historian data. */ +export async function modelStatus(): Promise { + try { + return fleetModelStatusSchema.parse( + await twinRequest('/model-status', undefined, 512_000) + ); + } catch { + console.warn( + JSON.stringify({ + level: 'warn', + service: 'api', + event: 'model_status_unavailable', + }) + ); + return { checkedAt: new Date().toISOString(), available: false, wellheads: [] }; + } +} + +/** Read bounded candidate metadata without exposing the service credential. */ +export async function snapshotCandidates(wellheadId: number, at: string) { + const result = snapshotListSchema.safeParse( + await twinRequest( + `/wellheads/${wellheadId}/snapshots?${new URLSearchParams({ at })}`, + undefined, + 32_000 + ) + ); + if (!result.success || result.data.wellheadId !== wellheadId) + throw new HTTPException(503, { message: 'Saved snapshots unavailable' }); + return result.data; +} diff --git a/monorepo/apps/api/src/utils/database-error.ts b/monorepo/apps/api/src/utils/database-error.ts new file mode 100644 index 0000000..c951658 --- /dev/null +++ b/monorepo/apps/api/src/utils/database-error.ts @@ -0,0 +1,16 @@ +type DatabaseError = { code?: unknown; cause?: unknown }; + +/** + * Find a Postgres error code, following the cause chain. + * + * Drizzle wraps driver failures in its own error, so the `code` that says a + * unique constraint was violated is on `error.cause`, not the error itself. + */ +export function errorCode(error: unknown, depth = 0): string | undefined { + if (typeof error !== 'object' || error === null || depth > 5) return undefined; + + const code = (error as DatabaseError).code; + if (typeof code === 'string') return code; + + return errorCode((error as DatabaseError).cause, depth + 1); +} diff --git a/monorepo/apps/api/src/utils/handler.ts b/monorepo/apps/api/src/utils/handler.ts index 6215eec..baee932 100644 --- a/monorepo/apps/api/src/utils/handler.ts +++ b/monorepo/apps/api/src/utils/handler.ts @@ -1,3 +1,4 @@ +import { errorCode as databaseErrorCode } from './database-error'; import type { ApiErr, ApiRes } from '@well-simulation/dto/res/response'; import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; @@ -17,8 +18,6 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'; /** Postgres unique-constraint violation. */ const UNIQUE_VIOLATION = '23505'; -type DatabaseError = { code?: unknown; cause?: unknown }; - /** * Strip bound query parameters from a database error message. * @@ -62,21 +61,6 @@ function serverResponse( return { msg, data, status, meta }; } -/** - * Find a Postgres error code, following the cause chain. - * - * Drizzle wraps driver failures in its own error, so the `code` that says a - * unique constraint was violated is on `error.cause`, not the error itself. - */ -function databaseErrorCode(error: unknown, depth = 0): string | undefined { - if (typeof error !== 'object' || error === null || depth > 5) return undefined; - - const code = (error as DatabaseError).code; - if (typeof code === 'string') return code; - - return databaseErrorCode((error as DatabaseError).cause, depth + 1); -} - function isZodError(error: unknown): boolean { return ( typeof error === 'object' && diff --git a/monorepo/apps/api/tests/alarm-lifecycle.test.ts b/monorepo/apps/api/tests/alarm-lifecycle.test.ts new file mode 100644 index 0000000..a51520e --- /dev/null +++ b/monorepo/apps/api/tests/alarm-lifecycle.test.ts @@ -0,0 +1,500 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { db, sql } from '@well-simulation/db/query'; +import type { + AlarmList, + AlarmReceipt, + AlarmShelfList, +} from '@well-simulation/dto/res/alarms'; +import { deleteTestUsers, request, signedInAs, withDb } from './helpers/api'; +import { resetRateLimits } from '../src/middleware/rate-limiter'; + +async function data(response: Response): Promise { + return ((await response.json()) as { data: T }).data; +} +const eventId = '9007199254740993'; +const triggeredAt = new Date().toISOString().replace(/\.[0-9]{3}Z$/, '.123456Z'); +const common = { + reason: 'Inspecting the instrument and current readings', + confirmed: true, +}; + +describe('Alarm operator lifecycle', () => { + let operator: Awaited>, + other: typeof operator, + reader: typeof operator, + admin: typeof operator; + let well: number, device: number, rule: number; + const write = (body: unknown, key = randomUUID(), cookie = operator.cookie) => + request('/alarms/actions', { + method: 'POST', + cookie, + body, + headers: { 'Idempotency-Key': key }, + }); + const ack = () => ({ action: 'acknowledge', eventId, triggeredAt, ...common }); + const shelve = (revision = 0) => ({ + action: 'shelve', + wellheadId: well, + ruleId: rule, + expectedRevision: revision, + durationMinutes: 30, + ...common, + }); + const unshelve = (revision = 1) => ({ + action: 'unshelve', + wellheadId: well, + ruleId: rule, + expectedRevision: revision, + ...common, + }); + const list = async (view = 'attention', cursor?: string) => { + const response = await request( + `/alarms?view=${view}&wellheadId=${well}${cursor ? '&cursor=' + encodeURIComponent(cursor) : ''}`, + { cookie: reader.cookie } + ); + expect(response.status).toBe(200); + return data(response); + }; + const shelves = async () => { + const response = await request(`/alarms/shelves?wellheadId=${well}`, { + cookie: reader.cookie, + }); + expect(response.status).toBe(200); + return data(response); + }; + async function clear() { + await withDb((client) => + client.query( + `DELETE FROM alarm_acknowledgement WHERE action_id IN (SELECT action_id FROM alarm_action WHERE wellhead_id=$1); + `.trim(), + [well] + ) + ); + await withDb(async (client) => { + await client.query('DELETE FROM alarm_shelf WHERE wellhead_id=$1', [well]); + await client.query('DELETE FROM alarm_action WHERE wellhead_id=$1', [well]); + await client.query('DELETE FROM alarmevent WHERE wellhead_id=$1', [well]); + }); + } + beforeAll(async () => { + await deleteTestUsers(); + operator = await signedInAs('OPERATIONS'); + other = await signedInAs('OPERATIONS'); + reader = await signedInAs('USER'); + admin = await signedInAs('ADMIN'); + await withDb(async (client) => { + device = ( + await client.query( + "INSERT INTO device(name,modbus_unit_id) VALUES ('alarm-lifecycle-test',247) RETURNING device_id" + ) + ).rows[0].device_id; + well = ( + await client.query( + "INSERT INTO wellhead(location_id,device_id,name) VALUES (1,$1,'Alarm lifecycle test well') RETURNING wellhead_id", + [device] + ) + ).rows[0].wellhead_id; + rule = ( + await client.query( + "SELECT alarm_rule_id FROM alarmrule WHERE severity_level='CRITICAL' ORDER BY alarm_rule_id LIMIT 1" + ) + ).rows[0].alarm_rule_id; + }); + }); + beforeEach(async () => { + await clear(); + await withDb(async (client) => { + await client.query( + "UPDATE users SET role='OPERATIONS',status='ACTIVE',deleted=false,session_version=0 WHERE id=$1", + [operator.user.id] + ); + await client.query( + "INSERT INTO alarmevent(event_id,alarm_rule_id,parameter_reading_id,timestamp_utc,wellhead_id,triggered_at,severity_level,triggered_value) VALUES($1,$2,1,$3,$4,$3,'CRITICAL',3200)", + [eventId, rule, triggeredAt, well] + ); + }); + }); + afterAll(async () => { + await clear(); + await withDb(async (client) => { + await client.query('DELETE FROM wellhead WHERE wellhead_id=$1', [well]); + await client.query('DELETE FROM device WHERE device_id=$1', [device]); + }); + await deleteTestUsers(); + }); + it('keeps the real application role unable to alter alarm truth or receipts', async () => { + const result = await db.execute(sql`SELECT current_user AS actor, + has_table_privilege(current_user,'alarmevent','UPDATE') AS events, + has_table_privilege(current_user,'alarm_action','UPDATE') AS receipts, + has_table_privilege(current_user,'alarm_acknowledgement','DELETE') AS acknowledgements, + has_column_privilege(current_user,'alarm_shelf','revision','UPDATE') AS shelves`); + expect(result.rows[0]).toMatchObject({ + actor: process.env.TEST_API_DB_USER ?? process.env.API_DB_USER, + events: false, + receipts: false, + acknowledgements: false, + shelves: true, + }); + }); + it('allows inspection but reserves all writes for OPERATIONS', async () => { + expect((await request('/alarms')).status).toBe(401); + for (const body of [ack(), shelve(), unshelve()]) + for (const cookie of [reader.cookie, admin.cookie]) + expect((await write(body, randomUUID(), cookie)).status).toBe(403); + expect((await list()).items[0]?.eventId).toBe(eventId); + }); + it('acknowledges the exact bigint and microsecond event without clearing it', async () => { + await withDb((client) => + client.query( + "INSERT INTO alarmevent SELECT event_id,alarm_rule_id,parameter_reading_id,timestamp_utc,wellhead_id,triggered_at+interval '1 microsecond',cleared_at,severity_level,triggered_value FROM alarmevent WHERE wellhead_id=$1", + [well] + ) + ); + const response = await write(ack()); + expect(response.status).toBe(200); + const result = await data(response); + expect(result.eventId).toBe(eventId); + expect(result.triggeredAt).toBe(triggeredAt); + const state = await list('active'); + expect(state.summary.active).toBe(2); + expect(state.summary.attention).toBe(1); + expect(state.items[0]?.acknowledgement?.actorName).toBe(operator.user.username); + expect(state.items[1]?.acknowledgement).toBeNull(); + }); + it('retains a returned-to-normal event in attention until acknowledged', async () => { + await withDb((client) => + client.query( + 'UPDATE alarmevent SET cleared_at=clock_timestamp() WHERE wellhead_id=$1', + [well] + ) + ); + expect((await list()).summary).toMatchObject({ active: 0, attention: 1 }); + expect((await write(ack())).status).toBe(200); + expect((await list()).summary.attention).toBe(0); + expect((await list('history')).items).toHaveLength(1); + }); + it('replays the same request and rejects a changed payload with that key', async () => { + const key = randomUUID(); + const first = await data(await write(ack(), key)); + const replay = await write(ack(), key); + expect(replay.status).toBe(200); + expect(await data(replay)).toEqual(first); + expect( + (await write({ ...ack(), reason: 'A different action reason' }, key)).status + ).toBe(409); + expect( + (await request('/alarms/actions/' + key, { cookie: other.cookie })).status + ).toBe(404); + expect( + await data(await request('/alarms/actions/' + key, { cookie: operator.cookie })) + ).toEqual(first); + await withDb(async (client) => { + expect( + ( + await client.query( + 'SELECT count(*)::int AS count FROM alarm_action WHERE wellhead_id=$1', + [well] + ) + ).rows[0].count + ).toBe(1); + expect( + ( + await client.query( + 'SELECT count(*)::int AS count FROM auditevent WHERE subject_id=$1', + [first.actionId] + ) + ).rows[0].count + ).toBe(1); + }); + }); + it('accepts equivalent UTC spellings for an idempotent acknowledgement', async () => { + const key = randomUUID(); + expect((await write(ack(), key)).status).toBe(200); + expect( + (await write({ ...ack(), triggeredAt: triggeredAt.replace('Z', '+00:00') }, key)) + .status + ).toBe(200); + }); + it('allows only one competing acknowledgement', async () => { + const responses = await Promise.all([ + write(ack()), + write(ack(), randomUUID(), other.cookie), + ]); + expect(responses.map((x) => x.status).sort()).toEqual([200, 409]); + }); + it('keeps active truth and counts while a shelf survives clear and retrigger', async () => { + const result = await data(await write(shelve())); + expect(Date.parse(result.shelvedUntil!) - Date.parse(result.occurredAt)).toBe( + 30 * 60000 + ); + expect((await list()).summary).toMatchObject({ + active: 1, + attention: 0, + shelves: 1, + }); + await withDb(async (client) => { + await client.query( + 'UPDATE alarmevent SET cleared_at=clock_timestamp() WHERE wellhead_id=$1', + [well] + ); + }); + expect((await shelves()).items).toHaveLength(1); + expect((await list('active')).items).toHaveLength(0); + await withDb((client) => + client.query( + "INSERT INTO alarmevent(event_id,alarm_rule_id,parameter_reading_id,timestamp_utc,wellhead_id,triggered_at,severity_level,triggered_value) VALUES($1,$2,1,clock_timestamp(),$3,clock_timestamp(),'CRITICAL',3201)", + [(BigInt(eventId) + 1n).toString(), rule, well] + ) + ); + expect((await list()).summary).toMatchObject({ + active: 1, + attention: 0, + shelves: 1, + }); + expect((await write(unshelve())).status).toBe(200); + expect((await list()).summary).toMatchObject({ + active: 1, + attention: 2, + shelves: 0, + }); + }); + it('expires shelves by database time without a cleanup worker', async () => { + expect((await write(shelve())).status).toBe(200); + await withDb((client) => + client.query( + "UPDATE alarm_shelf SET updated_at=statement_timestamp()-interval '6 minutes',shelved_until=statement_timestamp()-interval '1 minute' WHERE wellhead_id=$1", + [well] + ) + ); + expect((await shelves()).items).toHaveLength(0); + expect((await list()).summary.attention).toBe(1); + expect((await write(unshelve())).status).toBe(409); + expect((await write(shelve(1))).status).toBe(200); + }); + it('rejects stale shelf revisions and serializes the absent-row race', async () => { + const results = await Promise.all([ + write(shelve()), + write(shelve(), randomUUID(), other.cookie), + ]); + expect(results.map((x) => x.status).sort()).toEqual([200, 409]); + expect((await write(shelve(0))).status).toBe(409); + expect((await write(unshelve(0))).status).toBe(409); + const race = await Promise.all([ + write(shelve(1)), + write(unshelve(1), randomUUID(), other.cookie), + ]); + expect(race.map((x) => x.status).sort()).toEqual([200, 409]); + }); + it('rolls back receipt and state if auditing fails', async () => { + await withDb((client) => + client.query( + "CREATE FUNCTION test_refuse_alarm_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action LIKE 'alarm.%' THEN RAISE EXCEPTION 'test audit unavailable'; END IF; RETURN NEW; END $$; CREATE TRIGGER test_refuse_alarm_audit BEFORE INSERT ON auditevent FOR EACH ROW EXECUTE FUNCTION test_refuse_alarm_audit()" + ) + ); + try { + for (const body of [ack(), shelve()]) + expect((await write(body)).status).toBe(503); + expect((await list()).summary).toMatchObject({ attention: 1, shelves: 0 }); + await withDb(async (client) => + expect( + ( + await client.query( + 'SELECT count(*)::int AS count FROM alarm_action WHERE wellhead_id=$1', + [well] + ) + ).rows[0].count + ).toBe(0) + ); + } finally { + await withDb((client) => + client.query( + 'DROP TRIGGER test_refuse_alarm_audit ON auditevent; DROP FUNCTION test_refuse_alarm_audit()' + ) + ); + } + }); + it('rechecks current role and revoked sessions', async () => { + await withDb((client) => + client.query("UPDATE users SET role='USER' WHERE id=$1", [operator.user.id]) + ); + expect((await write(ack())).status).toBe(403); + await withDb((client) => + client.query( + "UPDATE users SET role='OPERATIONS',session_version=session_version+1 WHERE id=$1", + [operator.user.id] + ) + ); + expect((await write(ack())).status).toBe(401); + }); + it('validates duration, confirmation, reason, exact identity and unexpected fields', async () => { + for (const body of [ + { ...shelve(), durationMinutes: 4 }, + { ...shelve(), durationMinutes: 481 }, + { ...shelve(), durationMinutes: 5.5 }, + { ...shelve(), expectedRevision: -1 }, + { ...ack(), confirmed: false }, + { ...ack(), reason: ' ' }, + { ...ack(), eventId: Number(eventId) }, + { ...ack(), triggeredAt: triggeredAt.replace('123456', '1234567') }, + { ...ack(), actorUserId: other.user.id }, + ]) { + resetRateLimits(); + expect((await write(body)).status).toBe(400); + } + resetRateLimits(); + expect((await write({ ...ack(), reason: 'x'.repeat(5000) })).status).toBe(413); + expect( + ( + await request('/alarms/actions', { + method: 'POST', + cookie: operator.cookie, + body: ack(), + headers: { 'Idempotency-Key': randomUUID() }, + origin: 'https://untrusted.example', + }) + ).status + ).toBe(403); + }); + it('bounds page reads and preserves identities across pages', async () => { + await withDb((client) => + client.query( + "INSERT INTO alarmevent(event_id,alarm_rule_id,parameter_reading_id,timestamp_utc,wellhead_id,triggered_at,severity_level,triggered_value) SELECT $1::bigint+n,$2,1,$3::timestamptz,$4,$3::timestamptz+n*interval '1 microsecond','WARNING',3000 FROM generate_series(1,54) n", + [eventId, rule, triggeredAt, well] + ) + ); + const first = await list(); + expect(first.items).toHaveLength(50); + expect(first.summary.attention).toBe(55); + expect(first.nextCursor).not.toBeNull(); + const second = await list('attention', first.nextCursor!); + expect(second.items).toHaveLength(5); + expect(second.nextCursor).toBeNull(); + const newest = await list('history'); + const older = await list('history', newest.nextCursor!); + expect(newest.items[0]?.eventId).toBe((BigInt(eventId) + 54n).toString()); + expect(older.items.at(-1)?.eventId).toBe(eventId); + expect( + new Set([...newest.items, ...older.items].map((item) => item.eventId)).size + ).toBe(55); + expect( + new Set( + [...first.items, ...second.items].map((x) => x.eventId + ':' + x.triggeredAt) + ).size + ).toBe(55); + expect( + ( + await request( + '/alarms?view=history&wellheadId=' + well + '&cursor=' + first.nextCursor, + { cookie: reader.cookie } + ) + ).status + ).toBe(400); + expect( + (await request('/alarms?cursor=broken', { cookie: reader.cookie })).status + ).toBe(400); + }); + it('rolls back edits to an existing shelf when its audit insert fails', async () => { + expect((await write(shelve())).status).toBe(200); + const before = (await shelves()).items[0]; + await withDb((client) => + client.query( + "CREATE FUNCTION test_refuse_alarm_edit_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action LIKE 'alarm.%' THEN RAISE EXCEPTION 'test audit unavailable'; END IF; RETURN NEW; END $$; CREATE TRIGGER test_refuse_alarm_edit_audit BEFORE INSERT ON auditevent FOR EACH ROW EXECUTE FUNCTION test_refuse_alarm_edit_audit()" + ) + ); + try { + for (const body of [shelve(1), unshelve(1)]) + expect((await write(body)).status).toBe(503); + expect((await shelves()).items[0]).toEqual(before); + await withDb(async (client) => + expect( + ( + await client.query( + 'SELECT count(*)::int AS count FROM alarm_action WHERE wellhead_id=$1', + [well] + ) + ).rows[0].count + ).toBe(1) + ); + } finally { + await withDb((client) => + client.query( + 'DROP TRIGGER test_refuse_alarm_edit_audit ON auditevent; DROP FUNCTION test_refuse_alarm_edit_audit()' + ) + ); + } + }); + it('paginates current shelves independently of retained events', async () => { + const rules: number[] = []; + try { + await withDb(async (client) => { + const result = await client.query( + 'INSERT INTO alarmrule(parameter_type_id,severity_level,operator,threshold_value,active) SELECT parameter_type_id, severity_level, operator, 900000+n, false FROM alarmrule CROSS JOIN generate_series(1,55) n WHERE alarm_rule_id=$1 RETURNING alarm_rule_id', + [rule] + ); + rules.push(...result.rows.map((row) => row.alarm_rule_id)); + for (const id of rules) { + const actionId = randomUUID(); + await client.query( + "INSERT INTO alarm_action(action_id,actor_user_id,actor_name,actor_role,request_key,input_hash,action,wellhead_id,alarm_rule_id,occurred_at,input,result) VALUES($1,$2,'Page fixture','OPERATIONS',$3,$4,'shelve',$5,$6,statement_timestamp(),'{\"reason\":\"Page fixture\"}','{}')", + [actionId, operator.user.id, randomUUID(), 'a'.repeat(64), well, id] + ); + await client.query( + "INSERT INTO alarm_shelf(wellhead_id,alarm_rule_id,revision,shelved_until,updated_at,action_id) VALUES($1,$2,1,statement_timestamp()+interval '30 minutes',statement_timestamp(),$3)", + [well, id, actionId] + ); + } + }); + const first = await shelves(); + expect(first.total).toBe(55); + expect(first.items).toHaveLength(50); + const response = await request( + '/alarms/shelves?wellheadId=' + well + '&cursor=' + first.nextCursor, + { cookie: reader.cookie } + ); + expect(response.status).toBe(200); + const second = await data(response); + expect(second.items).toHaveLength(5); + expect(second.nextCursor).toBeNull(); + expect( + new Set([...first.items, ...second.items].map((item) => item.target.ruleId)) + .size + ).toBe(55); + expect((await list()).summary.active).toBe(1); + } finally { + await clear(); + await withDb((client) => + client.query('DELETE FROM alarmrule WHERE alarm_rule_id=ANY($1::int[])', [ + rules, + ]) + ); + } + }); + it('keeps receipts after event retention and exposes bounded action history', async () => { + const key = randomUUID(); + expect((await write(ack(), key)).status).toBe(200); + const detail = await request( + '/alarms/events/' + eventId + '?triggeredAt=' + encodeURIComponent(triggeredAt), + { cookie: reader.cookie } + ); + expect(detail.status).toBe(200); + expect((await data<{ actions: unknown[] }>(detail)).actions).toHaveLength(1); + await withDb((client) => + client.query('DELETE FROM alarmevent WHERE wellhead_id=$1', [well]) + ); + expect( + (await request('/alarms/actions/' + key, { cookie: operator.cookie })).status + ).toBe(200); + expect( + ( + await request( + '/alarms/events/' + + eventId + + '?triggeredAt=' + + encodeURIComponent(triggeredAt), + { cookie: reader.cookie } + ) + ).status + ).toBe(404); + }); +}); diff --git a/monorepo/apps/api/tests/alarms.test.ts b/monorepo/apps/api/tests/alarms.test.ts new file mode 100644 index 0000000..06c36aa --- /dev/null +++ b/monorepo/apps/api/tests/alarms.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + ALARM_SEVERITIES, + type ActiveAlarmsResponse, + type DashboardAnalyticsResponse, +} from '@well-simulation/dto/res/dashboard'; + +import { deleteTestUsers, request, signedInAs, withDb } from './helpers/api'; + +/** + * The Alarm Center's counts must cover every active alarm, even though its list + * is capped. + * + * Test rows carry parameter_reading_id -1, which no real reading has, so the + * suite removes exactly what it inserted. + */ + +const TEST_READING_ID = -1; + +async function clearTestAlarms(): Promise { + await withDb((client) => + client.query('DELETE FROM alarmEvent WHERE parameter_reading_id = $1', [ + TEST_READING_ID, + ]) + ); +} + +/** + * Open one alarm for every wellhead and rule pair, each a minute older than the + * last, and one cleared alarm that must not be counted. + */ +async function openAlarmForEveryRule(): Promise { + await withDb(async (client) => { + await client.query( + `INSERT INTO alarmEvent (alarm_rule_id, parameter_reading_id, timestamp_utc, + wellhead_id, triggered_at, severity_level, triggered_value) + SELECT ar.alarm_rule_id, $1, now(), wh.wellhead_id, + now() - (row_number() OVER (ORDER BY wh.wellhead_id, ar.alarm_rule_id)) + * interval '1 minute', + ar.severity_level, ar.threshold_value + FROM wellHead wh CROSS JOIN alarmRule ar`, + [TEST_READING_ID] + ); + await client.query( + `INSERT INTO alarmEvent (alarm_rule_id, parameter_reading_id, timestamp_utc, + wellhead_id, triggered_at, cleared_at, severity_level, + triggered_value) + SELECT alarm_rule_id, $1, now(), (SELECT min(wellhead_id) FROM wellHead), + now() - interval '1 day', now(), severity_level, threshold_value + FROM alarmRule ORDER BY alarm_rule_id LIMIT 1`, + [TEST_READING_ID] + ); + }); +} + +/** The counts the API should report, computed independently in SQL. */ +async function expectedTotals() { + return withDb(async (client) => { + const result = await client.query<{ + total: number; + critical: number; + warning: number; + affected: number; + }>( + `SELECT count(*)::int AS total, + (count(*) FILTER (WHERE severity_level = 'CRITICAL'))::int AS critical, + (count(*) FILTER (WHERE severity_level = 'WARNING'))::int AS warning, + count(DISTINCT wellhead_id)::int AS affected + FROM alarmEvent WHERE cleared_at IS NULL` + ); + return result.rows[0]!; + }); +} + +async function fetchAlarmCenter(cookie: string): Promise { + const response = await request('/dashboard/active-alarms', { cookie }); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: ActiveAlarmsResponse }; + return body.data; +} + +describe('Alarm Center data', () => { + let cookie: string; + + beforeAll(async () => { + await deleteTestUsers(); + await clearTestAlarms(); + ({ cookie } = await signedInAs('USER')); + }); + + afterAll(async () => { + await clearTestAlarms(); + await deleteTestUsers(); + }); + + it('reports zero totals and no rows when nothing is active', async () => { + const expected = await expectedTotals(); + // The suite's database receives no telemetry, so no other alarm is open. + expect(expected.total).toBe(0); + + const data = await fetchAlarmCenter(cookie); + expect(data.totals).toEqual({ + total: 0, + critical: 0, + warning: 0, + affectedWellheads: 0, + }); + expect(data.alarms).toEqual([]); + }); + + it('counts every active alarm even when the list is capped', async () => { + await openAlarmForEveryRule(); + const expected = await expectedTotals(); + // The cap must actually be exercised, or this test proves nothing. + expect(expected.total).toBeGreaterThan(50); + + const data = await fetchAlarmCenter(cookie); + expect(data.totals).toEqual({ + total: expected.total, + critical: expected.critical, + warning: expected.warning, + affectedWellheads: expected.affected, + }); + expect(data.alarms).toHaveLength(50); + }); + + it('ranks the list most severe first, then oldest first', async () => { + const data = await fetchAlarmCenter(cookie); + const rank = (severity: string | null | undefined) => + ALARM_SEVERITIES.indexOf(severity as (typeof ALARM_SEVERITIES)[number]); + + for (let index = 1; index < data.alarms.length; index += 1) { + const before = data.alarms[index - 1]!; + const after = data.alarms[index]!; + expect(rank(before.severityLevel)).toBeLessThanOrEqual(rank(after.severityLevel)); + if (before.severityLevel === after.severityLevel) { + expect(new Date(before.triggeredAt!).getTime()).toBeLessThanOrEqual( + new Date(after.triggeredAt!).getTime() + ); + } + } + + // The cut keeps critical alarms: none is dropped while a warning is shown. + const shownCritical = data.alarms.filter((a) => a.severityLevel === 'CRITICAL'); + expect(shownCritical).toHaveLength(Math.min(data.totals.critical, 50)); + }); +}); + +describe('Analytics alarm severity totals', () => { + let cookie: string; + + /** Open `count` alarms of one severity, `ageHours` hours ago. */ + async function openAlarms(severity: string, count: number, ageHours: number) { + await withDb((client) => + client.query( + `INSERT INTO alarmEvent (alarm_rule_id, parameter_reading_id, timestamp_utc, + wellhead_id, triggered_at, severity_level, triggered_value) + SELECT ar.alarm_rule_id, $1, now(), (SELECT min(wellhead_id) FROM wellHead), + now() - make_interval(hours => $2) - make_interval(secs => n), + ar.severity_level, ar.threshold_value + FROM generate_series(1, $3) AS n, + (SELECT * FROM alarmRule WHERE severity_level = $4 + ORDER BY alarm_rule_id LIMIT 1) AS ar`, + [TEST_READING_ID, ageHours, count, severity] + ) + ); + } + + /** Continuous aggregates update on a schedule; bring this one up to date now. */ + async function refreshAlarmCounts() { + await withDb((client) => + client.query( + "CALL refresh_continuous_aggregate('mv_5min_alarm_counts', NULL, NULL)" + ) + ); + } + + beforeAll(async () => { + await deleteTestUsers(); + await clearTestAlarms(); + ({ cookie } = await signedInAs('USER')); + }); + + afterAll(async () => { + await clearTestAlarms(); + await refreshAlarmCounts(); + await deleteTestUsers(); + }); + + it('counts only the last 24 hours, one total per severity', async () => { + await openAlarms('CRITICAL', 3, 0); + await openAlarms('WARNING', 2, 0); + // Older than the window: must not reach the total. + await openAlarms('CRITICAL', 4, 48); + await refreshAlarmCounts(); + + const response = await request('/dashboard/analytics', { cookie }); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: DashboardAnalyticsResponse }; + expect(body.data.alarmCounts).toEqual([ + { severityLevel: 'CRITICAL', alarmsTriggered: 3 }, + { severityLevel: 'WARNING', alarmsTriggered: 2 }, + ]); + }); +}); diff --git a/monorepo/apps/api/tests/auth.test.ts b/monorepo/apps/api/tests/auth.test.ts index 14a39ce..4b5caa7 100644 --- a/monorepo/apps/api/tests/auth.test.ts +++ b/monorepo/apps/api/tests/auth.test.ts @@ -1,5 +1,8 @@ +import { sign } from 'hono/jwt'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { config } from '../src/config'; + import { createUser, deleteTestUsers, @@ -108,3 +111,60 @@ describe('authentication', () => { expect(response.status).toBe(401); }); }); + +describe('session revocation (ADR 0037)', () => { + beforeAll(deleteTestUsers); + afterAll(deleteTestUsers); + + it('ends every session of the user on logout, including copies', async () => { + const user = await createUser('USER'); + const first = await login(user); + // A second device, or a copied cookie: both must stop working. + const second = await login(user); + + const logout = await request('/auth/logout', { method: 'POST', cookie: first }); + expect(logout.status).toBe(200); + + expect((await request('/auth/me', { cookie: first })).status).toBe(401); + expect((await request('/auth/me', { cookie: second })).status).toBe(401); + expect((await request('/dashboard/overview', { cookie: second })).status).toBe(401); + }); + + it('lets the user sign in again after logging out', async () => { + const user = await createUser('USER'); + const before = await login(user); + await request('/auth/logout', { method: 'POST', cookie: before }); + + const after = await login(user); + expect((await request('/auth/me', { cookie: after })).status).toBe(200); + }); + + it('refuses a correctly signed token that carries no session version', async () => { + // Shaped like a token issued before ADR 0037: valid signature, no `sv`. + const user = await createUser('USER'); + const token = await sign( + { + sub: String(user.id), + email: user.email, + role: user.role, + exp: Math.floor(Date.now() / 1000) + 3600, + }, + config.jwtSecret, + config.jwtAlgorithm + ); + + const response = await request('/auth/me', { cookie: `auth_token=${token}` }); + expect(response.status).toBe(401); + }); + + it('does not expose the session counter', async () => { + const user = await createUser('USER'); + const cookie = await login(user); + + const body = (await (await request('/auth/me', { cookie })).json()) as { + data: { user: Record }; + }; + expect(body.data.user).not.toHaveProperty('sessionVersion'); + expect(body.data.user).not.toHaveProperty('encryptedPassword'); + }); +}); diff --git a/monorepo/apps/api/tests/controls.test.ts b/monorepo/apps/api/tests/controls.test.ts new file mode 100644 index 0000000..a295da5 --- /dev/null +++ b/monorepo/apps/api/tests/controls.test.ts @@ -0,0 +1,495 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deleteTestUsers, request, signedInAs } from './helpers/api'; +import { withDb } from './helpers/api'; +import type { + controlCommand, + modelParameterChange, +} from '@well-simulation/db/schemas/controls'; + +type CommandResult = typeof controlCommand.$inferSelect & { + notificationPending?: boolean; +}; +type ParameterResult = typeof modelParameterChange.$inferSelect & { + restartRequired?: boolean; +}; + +async function responseData(response: Response): Promise { + return ((await response.json()) as { data: T }).data; +} + +const state = { + wellheadId: 1, + revision: 0, + modelVersion: '0.3.0', + parameterFingerprint: 'a'.repeat(64), + status: 'running', + lastTickAt: new Date().toISOString(), + controls: { + chokePositionPercent: 100, + masterValveOpen: true, + wingValveOpen: true, + swabValveOpen: false, + pumpOn: true, + emergencyShutdown: false, + }, +}; +const draftBody = { + wellheadId: 1, + expectedRevision: 0, + reason: 'Synthetic test', + targets: { pumpOn: false }, +}; + +async function clearCommands() { + await withDb(async (client) => { + await client.query( + 'DELETE FROM wellhead_control_state; DELETE FROM control_command_event; DELETE FROM control_command; DELETE FROM model_parameter_change; DELETE FROM wellhead_model_parameter' + ); + await client.query( + `INSERT INTO wellhead_control_state(wellhead_id,model_version,parameter_fingerprint,choke_position_percent,master_valve_open,wing_valve_open,swab_valve_open,pump_on,emergency_shutdown) VALUES(1,'0.3.0',$1,100,true,true,false,true,false)`, + ['a'.repeat(64)] + ); + }); +} + +describe('durable synthetic control API', () => { + let operator: Awaited>; + let administrator: Awaited>; + let reader: Awaited>; + beforeAll(async () => { + await clearCommands(); + await deleteTestUsers(); + operator = await signedInAs('OPERATIONS'); + administrator = await signedInAs('ADMIN'); + reader = await signedInAs('USER'); + }); + beforeEach(async () => { + await clearCommands(); + await withDb((client) => + client.query( + "UPDATE users SET role='OPERATIONS', status='ACTIVE', deleted=false, session_version=0 WHERE id=$1", + [operator.user.id] + ) + ); + vi.restoreAllMocks(); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + if (String(url).endsWith('/parameters')) + return Response.json({ + wellheadId: 1, + modelVersion: '0.3.0', + parameters: [ + { + key: 'downstream_pressure', + description: 'Surface or export pressure', + unit: 'psi', + defaultValue: 250, + minimum: 50, + maximum: 1000, + requiresRestart: true, + runningValue: 250, + }, + ], + }); + if (String(url).endsWith('/state')) + return Response.json({ + wellheadId: 1, + modelVersion: '0.3.0', + status: 'running', + }); + if (String(url).endsWith('/parameter-changes/validate')) + return Response.json({ + modelVersion: '0.3.0', + runningParameterFingerprint: 'a'.repeat(64), + proposedParameterFingerprint: 'b'.repeat(64), + restartRequired: true, + }); + if (String(url).endsWith('/controls')) + return Response.json({ ...state, lastTickAt: new Date().toISOString() }); + return Response.json({ status: 'notification_accepted' }, { status: 202 }); + }); + }); + afterAll(async () => { + vi.restoreAllMocks(); + await clearCommands(); + await deleteTestUsers(); + }); + + async function draft( + cookie = operator.cookie, + key = randomUUID(), + body: unknown = draftBody + ) { + return request('/twin/control-commands', { + method: 'POST', + cookie, + headers: { 'Idempotency-Key': key }, + body, + }); + } + async function activate(id: string, cookie = operator.cookie) { + return request(`/twin/control-commands/${id}/activate`, { + method: 'POST', + cookie, + body: { confirm: true, expectedRevision: 0 }, + }); + } + async function draftId() { + const response = await draft(); + expect(response.status).toBe(201); + return (await responseData(response)).commandId; + } + + it('keeps USER and ADMIN outside control authority', async () => { + expect((await draft(reader.cookie)).status).toBe(403); + expect((await draft(administrator.cookie)).status).toBe(403); + expect( + (await request('/twin/wellheads/1/controls', { cookie: reader.cookie })).status + ).toBe(200); + }); + + it('recovers only the current creator proposals by exact idempotency key without the model', async () => { + const key = randomUUID(); + const created = await draft(operator.cookie, key); + const row = await responseData(created); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Model down')); + const path = `/twin/wellheads/1/control-commands?idempotencyKey=${key}`; + const recovered = await request(path, { cookie: operator.cookie }); + expect(recovered.status).toBe(200); + const data = await responseData<{ items: CommandResult[]; hasMore: boolean }>( + recovered + ); + expect(data.items.map((item) => item.commandId)).toEqual([row.commandId]); + expect(data.hasMore).toBe(false); + expect((await request(path, { cookie: reader.cookie })).status).toBe(403); + expect((await request(path, { cookie: administrator.cookie })).status).toBe(403); + const another = await signedInAs('OPERATIONS'); + expect( + ( + await responseData<{ items: unknown[] }>( + await request(path, { cookie: another.cookie }) + ) + ).items + ).toEqual([]); + expect( + ( + await request('/twin/wellheads/1/control-commands?offset=-1', { + cookie: operator.cookie, + }) + ).status + ).toBe(400); + expect( + ( + await request('/twin/wellheads/1/control-commands?offset=10001', { + cookie: operator.cookie, + }) + ).status + ).toBe(400); + expect( + ( + await request('/twin/wellheads/1/control-commands?idempotencyKey=no', { + cookie: operator.cookie, + }) + ).status + ).toBe(400); + await withDb((client) => + client.query("UPDATE users SET role='USER' WHERE id=$1", [operator.user.id]) + ); + expect((await request(path, { cookie: operator.cookie })).status).toBe(403); + }); + + it('exposes authoritative parameter metadata only to administrators and rejects malformed metadata', async () => { + const path = '/twin/wellheads/1/parameters'; + expect((await request(path, { cookie: reader.cookie })).status).toBe(403); + expect((await request(path, { cookie: operator.cookie })).status).toBe(403); + const response = await request(path, { cookie: administrator.cookie }); + expect(response.status).toBe(200); + const data = await responseData<{ + catalogue: { parameters: { unit: string; minimum: number }[] }; + }>(response); + expect(data.catalogue.parameters[0]).toMatchObject({ unit: 'psi', minimum: 50 }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ wellheadId: 2, modelVersion: '0.3.0', status: 'running' }) + ); + expect((await request(path, { cookie: administrator.cookie })).status).toBe(503); + }); + + it('keeps proposal recovery available during a slow parameter read and rechecks authority', async () => { + let release = () => {}; + let entered = () => {}; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const waiting = new Promise((resolve) => { + entered = resolve; + }); + const original = vi.mocked(globalThis.fetch).getMockImplementation()!; + vi.mocked(globalThis.fetch).mockImplementation(async (url, init) => { + if (String(url).endsWith('/parameters')) { + entered(); + await blocked; + } + return original(url, init); + }); + const pending = request('/twin/wellheads/1/parameters', { + cookie: administrator.cookie, + }); + try { + await waiting; + expect( + ( + await request('/twin/wellheads/1/parameter-changes', { + cookie: administrator.cookie, + }) + ).status + ).toBe(200); + await withDb((client) => + client.query("UPDATE users SET role='USER' WHERE id=$1", [ + administrator.user.id, + ]) + ); + } finally { + release(); + } + expect((await pending).status).toBe(403); + await withDb((client) => + client.query("UPDATE users SET role='ADMIN' WHERE id=$1", [administrator.user.id]) + ); + }); + + it('bounds proposal pages and preserves their stable ordering', async () => { + await withDb((client) => + client.query( + `INSERT INTO control_command(command_id, wellhead_id, created_by, idempotency_key, reason, expected_revision, model_version, parameter_fingerprint, pump_on, status, result_at) + SELECT gen_random_uuid(),1,$1,gen_random_uuid(),'History check',0,'0.3.0',$2,false,'cancelled',clock_timestamp() FROM generate_series(1,24)`, + [operator.user.id, 'a'.repeat(64)] + ) + ); + const first = await responseData<{ items: CommandResult[]; hasMore: boolean }>( + await request('/twin/wellheads/1/control-commands', { cookie: operator.cookie }) + ); + const next = await responseData<{ items: CommandResult[]; hasMore: boolean }>( + await request('/twin/wellheads/1/control-commands?offset=20', { + cookie: operator.cookie, + }) + ); + expect(first.items).toHaveLength(20); + expect(first.hasMore).toBe(true); + expect(next.items).toHaveLength(4); + expect(next.hasMore).toBe(false); + expect( + new Set([...first.items, ...next.items].map((item) => item.commandId)).size + ).toBe(24); + }); + it('rejects unknown, null, empty, out-of-range, and mixed shutdown targets', async () => { + for (const targets of [ + { pressure: 20 }, + { pumpOn: null }, + {}, + { chokePositionPercent: 101 }, + { emergencyShutdown: false, pumpOn: true }, + ]) { + expect( + (await draft(operator.cookie, randomUUID(), { ...draftBody, targets })).status + ).toBe(400); + } + }); + it('deduplicates a draft and refuses changed content under the same key', async () => { + const key = randomUUID(); + const first = await draft(operator.cookie, key); + const second = await draft(operator.cookie, key); + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect((await responseData(first)).commandId).toBe( + (await responseData(second)).commandId + ); + expect( + (await draft(operator.cookie, key, { ...draftBody, reason: 'different' })).status + ).toBe(409); + }); + it('queues once with an atomic audit and an empty wake-up hint', async () => { + const id = await draftId(); + const response = await activate(id); + expect(response.status).toBe(202); + expect((await responseData(response)).status).toBe('queued'); + expect((await activate(id)).status).toBe(202); + await withDb(async (client) => { + const event = await client.query( + "SELECT count(*)::int AS n FROM control_command_event WHERE command_id=$1 AND event_type='queued'", + [id] + ); + expect(event.rows[0].n).toBe(1); + const audit = await client.query( + "SELECT count(*)::int AS n FROM auditevent WHERE subject_id=$1 AND action='control.queued'", + [id] + ); + expect(audit.rows[0].n).toBe(1); + const active = await client.query( + 'SELECT revision FROM wellhead_control_state WHERE wellhead_id=1' + ); + expect(active.rows[0].revision).toBe(0); + }); + const hints = vi + .mocked(fetch) + .mock.calls.filter(([url]) => String(url).endsWith('/notify')); + expect(hints.length).toBe(2); + expect(hints[0]![1]!.body).toBe('{}'); + }); + it('allows cancellation and never requeues a terminal command', async () => { + const id = await draftId(); + await activate(id); + const cancelled = await request(`/twin/control-commands/${id}/cancel`, { + method: 'POST', + cookie: operator.cookie, + body: { confirm: true }, + }); + expect(cancelled.status).toBe(200); + expect((await responseData(cancelled)).status).toBe('cancelled'); + expect((await responseData(await activate(id))).status).toBe( + 'cancelled' + ); + }); + it('checks the database role again even while the JWT still says OPERATIONS', async () => { + const id = await draftId(); + await withDb((client) => + client.query("UPDATE users SET role='USER' WHERE id=$1", [operator.user.id]) + ); + expect((await activate(id)).status).toBe(403); + }); + it('rejects disabled accounts and a revoked session', async () => { + const id = await draftId(); + await withDb((client) => + client.query("UPDATE users SET status='DISABLED' WHERE id=$1", [operator.user.id]) + ); + expect((await activate(id)).status).toBe(403); + await withDb((client) => + client.query("UPDATE users SET status='ACTIVE',session_version=1 WHERE id=$1", [ + operator.user.id, + ]) + ); + expect((await activate(id)).status).toBe(401); + }); + it('retains a committed queue entry when its HTTP hint fails', async () => { + const id = await draftId(); + vi.mocked(fetch).mockImplementation(async (url) => { + if (String(url).endsWith('/controls')) + return Response.json({ ...state, lastTickAt: new Date().toISOString() }); + throw new Error('connection lost'); + }); + const response = await activate(id); + expect(response.status).toBe(202); + expect((await responseData(response)).notificationPending).toBe( + true + ); + }); + it('serializes concurrent activations into one queued event', async () => { + const id = await draftId(); + const responses = await Promise.all([activate(id), activate(id)]); + expect(responses.map((r) => r.status)).toEqual([202, 202]); + await withDb(async (client) => { + const result = await client.query( + "SELECT count(*)::int AS n FROM control_command_event WHERE command_id=$1 AND event_type='queued'", + [id] + ); + expect(result.rows[0].n).toBe(1); + }); + }); + it('rolls back activation when its audit insert fails', async () => { + const id = await draftId(); + await withDb((client) => + client.query( + `CREATE FUNCTION test_refuse_control_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='control.queued' THEN RAISE EXCEPTION 'test audit unavailable'; END IF; RETURN NEW; END $$; CREATE TRIGGER test_refuse_control_audit BEFORE INSERT ON auditevent FOR EACH ROW EXECUTE FUNCTION test_refuse_control_audit()` + ) + ); + try { + expect((await activate(id)).status).toBe(503); + await withDb(async (client) => { + const result = await client.query( + 'SELECT status FROM control_command WHERE command_id=$1', + [id] + ); + expect(result.rows[0].status).toBe('draft'); + }); + } finally { + await withDb((client) => + client.query( + 'DROP TRIGGER test_refuse_control_audit ON auditevent; DROP FUNCTION test_refuse_control_audit()' + ) + ); + } + }); + it('uses a separate ADMIN proposal and reports publication requiring restart', async () => { + const body = { + wellheadId: 1, + modelVersion: '0.3.0', + parameterKey: 'downstream_pressure', + value: 260, + unit: 'psi', + expectedOverrideId: null, + reason: 'Reference well test', + }; + const headers = { 'Idempotency-Key': randomUUID() }; + expect( + ( + await request('/twin/parameter-changes', { + method: 'POST', + cookie: operator.cookie, + headers, + body, + }) + ).status + ).toBe(403); + const proposed = await request('/twin/parameter-changes', { + method: 'POST', + cookie: administrator.cookie, + headers, + body, + }); + expect(proposed.status).toBe(201); + const id = (await responseData(proposed)).changeId; + const recovered = await request( + `/twin/wellheads/1/parameter-changes?idempotencyKey=${headers['Idempotency-Key']}`, + { cookie: administrator.cookie } + ); + expect(recovered.status).toBe(200); + expect( + (await responseData<{ items: ParameterResult[] }>(recovered)).items[0]?.changeId + ).toBe(id); + expect( + ( + await request('/twin/wellheads/1/parameter-changes', { + cookie: operator.cookie, + }) + ).status + ).toBe(403); + const publish = await request(`/twin/parameter-changes/${id}/publish`, { + method: 'POST', + cookie: administrator.cookie, + body: { confirm: true }, + }); + expect(publish.status).toBe(200); + const result = await responseData(publish); + expect(result.status).toBe('published'); + expect(result.restartRequired).toBe(true); + const stateResponse = await request('/twin/wellheads/1/parameters', { + cookie: administrator.cookie, + }); + expect(stateResponse.status).toBe(200); + const parameters = await responseData<{ + latestOverrides: { parameterOverrideId: number }[]; + }>(stateResponse); + expect(parameters.latestOverrides[0]?.parameterOverrideId).toBe( + result.publishedOverrideId + ); + await withDb(async (client) => { + const row = await client.query( + 'SELECT changed_by,value FROM wellhead_model_parameter WHERE parameter_override_id=$1', + [result.publishedOverrideId] + ); + expect(row.rows[0]).toEqual({ + changed_by: String(administrator.user.id), + value: 260, + }); + }); + }); +}); diff --git a/monorepo/apps/api/tests/forecast-schedule.test.ts b/monorepo/apps/api/tests/forecast-schedule.test.ts new file mode 100644 index 0000000..3e64c77 --- /dev/null +++ b/monorepo/apps/api/tests/forecast-schedule.test.ts @@ -0,0 +1,205 @@ +import { db, sql } from '@well-simulation/db/query'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { deleteTestUsers, request, signedInAs, withDb } from './helpers/api'; +import { + forecastScheduleSchema, + type ForecastScheduleView, +} from '@well-simulation/dto/res/forecast-schedule'; +async function data(response: Response): Promise { + return ((await response.json()) as { data: T }).data; +} +const path = '/twin/wellheads/1/forecast-schedule'; +const draft = { + expectedRevision: 0, + enabled: false, + intervalMinutes: 15, + durationMinutes: 15, + reason: 'Keep a recent result for this well', + confirmed: true, +}; + +describe('Scheduled forecast configuration', () => { + let admin: Awaited>; + let reader: Awaited>; + let otherAdmin: Awaited>; + let operator: Awaited>; + beforeAll(async () => { + await deleteTestUsers(); + admin = await signedInAs('ADMIN'); + reader = await signedInAs('USER'); + operator = await signedInAs('OPERATIONS'); + otherAdmin = await signedInAs('ADMIN'); + }); + beforeEach(async () => { + await withDb(async (db) => { + await db.query('DELETE FROM forecast_job'); + await db.query('DELETE FROM forecast_schedule'); + await db.query("DELETE FROM auditevent WHERE action='forecast_schedule.updated'"); + await db.query("UPDATE users SET role='ADMIN', status='ACTIVE' WHERE id=$1", [ + admin.user.id, + ]); + }); + }); + afterAll(async () => { + await withDb(async (db) => { + await db.query('DELETE FROM forecast_job'); + await db.query('DELETE FROM forecast_schedule'); + }); + await deleteTestUsers(); + }); + it('uses application permissions rather than a database administrator', async () => { + const result = await db.execute( + sql`SELECT current_user AS actor, has_table_privilege(current_user, 'wellhead', 'UPDATE') AS may_update_assets, has_table_privilege(current_user, 'forecast_schedule', 'UPDATE') AS may_update_schedules` + ); + expect(result.rows[0]).toMatchObject({ + actor: process.env.TEST_API_DB_USER ?? process.env.API_DB_USER, + may_update_assets: false, + may_update_schedules: true, + }); + }); + it('lets readers inspect empty schedules but reserves writes for administrators', async () => { + expect((await request(path)).status).toBe(401); + const read = await request(path, { cookie: reader.cookie }); + expect(read.status).toBe(200); + expect(await data(read)).toEqual({ + schedule: null, + latestJob: null, + latestCompleted: null, + }); + for (const cookie of [reader.cookie, operator.cookie]) + expect((await request(path, { method: 'PUT', cookie, body: draft })).status).toBe( + 403 + ); + expect( + (await request(path, { method: 'PUT', cookie: admin.cookie, body: draft })).status + ).toBe(200); + }); + it('validates bounds, explicit confirmation, identifiers and unknown inputs', async () => { + for (const change of [ + { intervalMinutes: 4 }, + { intervalMinutes: 1441 }, + { intervalMinutes: 5.5 }, + { durationMinutes: 31 }, + { durationMinutes: 4 }, + { reason: ' ' }, + { confirmed: false }, + { expectedRevision: -1 }, + { ownerUserId: 1 }, + ]) { + expect( + ( + await request(path, { + method: 'PUT', + cookie: admin.cookie, + body: { ...draft, ...change }, + }) + ).status + ).toBe(400); + } + expect( + ( + await request('/twin/wellheads/2147483648/forecast-schedule', { + cookie: reader.cookie, + }) + ).status + ).toBe(400); + expect( + ( + await request('/twin/wellheads/2147483647/forecast-schedule', { + cookie: reader.cookie, + }) + ).status + ).toBe(404); + }); + it('records attributed edits, rejects old revisions and starts a new interval', async () => { + const first = await request(path, { + method: 'PUT', + cookie: admin.cookie, + body: draft, + }); + const initial = forecastScheduleSchema.parse(await data(first)); + expect(initial).toMatchObject({ + enabled: false, + nextDueAt: null, + ownerUserId: admin.user.id, + revision: 1, + }); + expect( + (await request(path, { method: 'PUT', cookie: admin.cookie, body: draft })).status + ).toBe(409); + const second = await request(path, { + method: 'PUT', + cookie: admin.cookie, + body: { ...draft, expectedRevision: 1, enabled: true, intervalMinutes: 5 }, + }); + const updated = forecastScheduleSchema.parse(await data(second)); + expect(updated.revision).toBe(2); + expect( + Date.parse(updated.nextDueAt ?? 'invalid') - Date.parse(updated.updatedAt) + ).toBeCloseTo(300_000, -1); + await withDb(async (db) => { + const audit = await db.query( + "SELECT actor_user_id, detail FROM auditevent WHERE action='forecast_schedule.updated' ORDER BY occurred_at DESC" + ); + expect(audit.rows).toHaveLength(2); + expect(audit.rows[0]).toMatchObject({ + actor_user_id: admin.user.id, + detail: { revision: 2, reason: draft.reason }, + }); + }); + }); + it('rejects competing owners without overwriting the winning create', async () => { + const responses = await Promise.all([ + request(path, { method: 'PUT', cookie: admin.cookie, body: draft }), + request(path, { + method: 'PUT', + cookie: otherAdmin.cookie, + body: { ...draft, intervalMinutes: 5 }, + }), + ]); + expect(responses.map((response) => response.status).sort()).toEqual([200, 409]); + await withDb(async (db) => + expect((await db.query('SELECT revision FROM forecast_schedule')).rows).toEqual([ + { revision: 1 }, + ]) + ); + }); + it('rechecks current authority even while a signed token still says ADMIN', async () => { + await withDb((db) => + db.query("UPDATE users SET role='USER' WHERE id=$1", [admin.user.id]) + ); + expect( + (await request(path, { method: 'PUT', cookie: admin.cookie, body: draft })).status + ).toBe(403); + await withDb((db) => + db.query("UPDATE users SET role='ADMIN', status='DISABLED' WHERE id=$1", [ + admin.user.id, + ]) + ); + expect([401, 403]).toContain( + (await request(path, { method: 'PUT', cookie: admin.cookie, body: draft })).status + ); + }); + it('rolls back settings when their audit record fails', async () => { + await withDb((db) => + db.query( + "CREATE FUNCTION test_refuse_forecast_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='forecast_schedule.updated' THEN RAISE EXCEPTION 'test audit unavailable'; END IF; RETURN NEW; END $$; CREATE TRIGGER test_refuse_forecast_audit BEFORE INSERT ON auditevent FOR EACH ROW EXECUTE FUNCTION test_refuse_forecast_audit()" + ) + ); + try { + expect( + (await request(path, { method: 'PUT', cookie: admin.cookie, body: draft })) + .status + ).toBe(503); + const response = await request(path, { cookie: admin.cookie }); + const view: ForecastScheduleView = await data(response); + expect(view.schedule).toBeNull(); + } finally { + await withDb((db) => + db.query( + 'DROP TRIGGER test_refuse_forecast_audit ON auditevent; DROP FUNCTION test_refuse_forecast_audit()' + ) + ); + } + }); +}); diff --git a/monorepo/apps/api/tests/metrics.test.ts b/monorepo/apps/api/tests/metrics.test.ts new file mode 100644 index 0000000..0306be1 --- /dev/null +++ b/monorepo/apps/api/tests/metrics.test.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { db } from '@well-simulation/db/query'; +import { databaseReady, readinessCheckedAt } from '../src/metrics'; +import { request } from './helpers/api'; + +afterEach(() => vi.restoreAllMocks()); + +it('reports the last completed readiness probe without probing during a scrape', async () => { + databaseReady.set(0); + readinessCheckedAt.set(0); + const execute = vi.spyOn(db, 'execute'); + expect((await request('/ready')).status).toBe(200); + const first = await (await request('/metrics')).text(); + expect(first).toContain('well_sim_api_database_ready 1'); + expect(execute).toHaveBeenCalledTimes(1); + const checked = (await readinessCheckedAt.get()).values[0]?.value; + expect(checked).toBeGreaterThan(Date.now() / 1000 - 5); + + execute.mockRejectedValueOnce(new Error('database unavailable')); + expect((await request('/ready')).status).toBe(503); + const failed = await (await request('/metrics')).text(); + expect(failed).toContain('well_sim_api_database_ready 0'); + expect(execute).toHaveBeenCalledTimes(2); + expect(failed).not.toContain('database unavailable'); +}); diff --git a/monorepo/apps/api/tests/rate-limit.test.ts b/monorepo/apps/api/tests/rate-limit.test.ts index 111f21b..a690f15 100644 --- a/monorepo/apps/api/tests/rate-limit.test.ts +++ b/monorepo/apps/api/tests/rate-limit.test.ts @@ -136,6 +136,30 @@ describe('budget enforcement', () => { expect(response?.status).toBe(429); }); + it.each(['/auth/register', '/auth/logout'])( + 'gives %s the strict budget too', + async (path) => { + for (let i = 0; i < 10; i += 1) await send(100, path); + const { response } = await send(100, path); + expect(response?.status).toBe(429); + } + ); + + it('keeps /auth/me on the normal budget, since it checks no password', async () => { + // The dashboard checks the session on every page load. Twenty checks in a + // minute is ordinary use, not credential guessing. + for (let i = 0; i < 20; i += 1) { + const { response } = await send(100, '/auth/me'); + expect(response).toBeUndefined(); + } + }); + + it('does not let session checks spend the login budget', async () => { + for (let i = 0; i < 20; i += 1) await send(100, '/auth/me'); + const { response } = await send(100, '/auth/login'); + expect(response).toBeUndefined(); + }); + it('keeps auth and dashboard budgets in separate buckets', async () => { for (let i = 0; i < 10; i += 1) await send(100, '/auth/login'); const { response } = await send(100, '/dashboard/overview'); diff --git a/monorepo/apps/api/tests/setup/global-setup.ts b/monorepo/apps/api/tests/setup/global-setup.ts index c988978..2618ae0 100644 --- a/monorepo/apps/api/tests/setup/global-setup.ts +++ b/monorepo/apps/api/tests/setup/global-setup.ts @@ -4,13 +4,13 @@ import { Client } from 'pg'; import { REPO_ROOT, TEST_DATABASE, databaseUrl, serverTarget } from './env'; /** - * Create a disposable database, apply the canonical migrations, and drop it + * Create a disposable database, apply the canonical schema, and drop it * again when the suite finishes. * - * Migrations are applied by running `data/src/schema.py`, the same migrator the + * The schema is applied by running `data/src/schema.py`, the same migrator the * compose stack uses, rather than by a second implementation living in the test * harness. That keeps one applier (Standard 6) and means the suite also - * proves the migrations apply cleanly to an empty database. + * proves the schema installs cleanly to an empty database. */ async function withMaintenanceClient( @@ -45,7 +45,7 @@ async function dropTestDatabase(client: Client): Promise { await client.query(`DROP DATABASE IF EXISTS "${TEST_DATABASE}"`); } -function applyMigrations(): void { +function applySchema(): void { const { host, port, user, password } = serverTarget(); const python = process.env.PYTHON_BIN ?? 'python3'; @@ -70,19 +70,43 @@ export async function setup(): Promise { }); try { - applyMigrations(); + applySchema(); } catch (cause) { - const detail = - cause instanceof Error && 'stderr' in cause - ? String((cause as { stderr?: Buffer }).stderr ?? '') - : ''; throw new Error( - `Failed to apply migrations to the test database.\n` + - `Ensure the migrator's dependencies are installed: pip install -r requirements-dev.txt\n${detail}` + `Failed to install the schema to the test database.\n` + + `Ensure the migrator's dependencies are installed: pip install -r requirements-dev.txt\n` + + describeMigratorFailure(cause) ); } } +/** + * Report everything the failed migrator run left behind. + * + * The migrator writes its JSON log to stdout, not stderr, so reporting stderr + * alone hid its own "Migration failed" message. A spawn failure (python3 + * missing) or a kill signal leaves both streams empty, and only `code`, + * `status`, or `signal` explain it. + */ +function describeMigratorFailure(cause: unknown): string { + if (!(cause instanceof Error)) return `Unknown failure: ${String(cause)}`; + const failure = cause as Error & { + code?: string; + status?: number | null; + signal?: string | null; + stdout?: Buffer | string; + stderr?: Buffer | string; + }; + return [ + `error: ${failure.message}`, + `code: ${failure.code ?? 'none'}`, + `exit status: ${failure.status ?? 'none'}`, + `signal: ${failure.signal ?? 'none'}`, + `stdout:\n${String(failure.stdout ?? '').trim() || '(empty)'}`, + `stderr:\n${String(failure.stderr ?? '').trim() || '(empty)'}`, + ].join('\n'); +} + export async function teardown(): Promise { if (process.env.TEST_KEEP_DATABASE) return; await withMaintenanceClient(dropTestDatabase); diff --git a/monorepo/apps/api/tests/setup/setup-file.ts b/monorepo/apps/api/tests/setup/setup-file.ts index c9a340a..85082c6 100644 --- a/monorepo/apps/api/tests/setup/setup-file.ts +++ b/monorepo/apps/api/tests/setup/setup-file.ts @@ -14,9 +14,23 @@ import { TEST_DATABASE, databaseUrl, loadRepoEnv } from './env'; loadRepoEnv(); process.env.DATABASE_URL = databaseUrl(TEST_DATABASE); +// Requests use the deployed grants; fixture setup keeps its separate admin connection. +const applicationUser = process.env.TEST_API_DB_USER ?? process.env.API_DB_USER; +if (!applicationUser || !process.env.API_DB_PASSWORD) { + throw new Error( + 'API tests require API_DB_USER and API_DB_PASSWORD for a restricted application role' + ); +} +const applicationUrl = new URL(process.env.DATABASE_URL); +applicationUrl.username = applicationUser; +applicationUrl.password = process.env.API_DB_PASSWORD; +process.env.DATABASE_URL = applicationUrl.toString(); process.env.NODE_ENV = 'test'; process.env.JWT_SECRET = process.env.TEST_JWT_SECRET ?? 'test-secret-of-at-least-thirty-two-characters'; +process.env.TWIN_INTERNAL_TOKEN = + process.env.TEST_TWIN_INTERNAL_TOKEN ?? + 'test-twin-token-of-at-least-thirty-two-characters'; process.env.COOKIE_SECURE = 'false'; process.env.TRUSTED_PROXY_IPS = ''; process.env.ALLOWED_ORIGINS = ''; diff --git a/monorepo/apps/api/tests/twin.test.ts b/monorepo/apps/api/tests/twin.test.ts new file mode 100644 index 0000000..a673e19 --- /dev/null +++ b/monorepo/apps/api/tests/twin.test.ts @@ -0,0 +1,494 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { deleteTestUsers, request, signedInAs, withDb } from './helpers/api'; + +import type { + ProjectionDetail, + RunHistory, +} from '@well-simulation/dto/res/projection-detail'; +import type { FleetModelStatus } from '@well-simulation/dto/res/model-status'; +import type { DashboardOverviewResponse } from '@well-simulation/dto/res/dashboard'; + +/** Read the standard test envelope; each test asserts the fields it exercises. */ +async function responseData(response: Response): Promise { + return ((await response.json()) as { data: T }).data; +} + +const runId = randomUUID(); +const scenarioId = randomUUID(); +const baseAt = new Date().toISOString(); +const forecastAt = new Date(Date.now() + 300_000).toISOString(); + +describe('Milestone 8 projection API', () => { + let cookie: string; + let actor: number; + + beforeAll(async () => { + await deleteTestUsers(); + const session = await signedInAs('USER'); + cookie = session.cookie; + actor = session.user.id; + }); + + afterAll(async () => { + vi.restoreAllMocks(); + await withDb(async (client) => { + await client.query('DELETE FROM prediction_point WHERE run_id = $1', [runId]); + await client.query('DELETE FROM prediction_run WHERE run_id = $1', [runId]); + await client.query('DELETE FROM scenario_point WHERE run_id = $1', [scenarioId]); + await client.query('DELETE FROM scenario_run WHERE run_id = $1', [scenarioId]); + }); + await deleteTestUsers(); + }); + + it('requires a session and validates bounded requests', async () => { + const unsigned = await request('/twin/wellheads/1/forecast', { + method: 'POST', + body: {}, + }); + expect(unsigned.status).toBe(401); + + const invalid = await request('/twin/wellheads/1/forecast', { + method: 'POST', + cookie, + body: { horizonSeconds: 7201 }, + }); + expect(invalid.status).toBe(400); + + const oversized = await request('/twin/wellheads/1/forecast', { + method: 'POST', + cookie, + body: { padding: 'x'.repeat(5000) }, + }); + expect(oversized.status).toBe(413); + }); + + it('passes only the server-side token and actor to twin-core', async () => { + const payload = { + runId, + kind: 'forecast', + wellheadId: 1, + name: null, + source: 'synthetic_reduced_order_model', + fieldCalibrated: false, + modelVersion: '1.0.0', + baseSimulatedAt: baseAt, + generatedAt: baseAt, + horizonSeconds: 300, + stepSeconds: 300, + confidence: { + method: 'heuristic_uncalibrated', + score: 0.55, + label: 'moderate', + reasons: ['Synthetic model'], + }, + points: [ + { + elapsedSeconds: 300, + timestamp: forecastAt, + values: { flow_rate: 100 }, + alarmRisk: 'none', + triggeredRuleIds: [], + }, + ], + }; + const call = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(payload), { status: 201 })); + + const response = await request('/twin/wellheads/1/forecast', { + method: 'POST', + cookie, + body: { horizonSeconds: 300, stepSeconds: 300 }, + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: typeof payload }; + expect(body.data.runId).toBe(runId); + const [url, init] = call.mock.calls[0]!; + expect(String(url)).toContain('/wellheads/1/forecast'); + const headers = new Headers(init?.headers); + expect(headers.get('x-twin-actor-id')).toBe(String(actor)); + expect(headers.get('x-twin-internal-token')?.length).toBeGreaterThanOrEqual(32); + expect(JSON.stringify(body)).not.toContain(headers.get('x-twin-internal-token')); + call.mockRestore(); + }); + + it('loads a complete saved forecast and rejects partially expired points', async () => { + await withDb(async (client) => { + await client.query( + `INSERT INTO prediction_run + (run_id, created_at, wellhead_id, requested_by_user_id, model_version, + parameter_fingerprint, base_simulated_at, horizon_seconds, step_seconds, + base_state, parameter_values, control_values, alarm_rules, + confidence_score, confidence_reasons) + VALUES ($1, $2, 1, $3, '1.0.0', 'fingerprint', $2, 300, 300, + '{}', '{}', '{}', '[]', 0.55, '["Synthetic model"]')`, + [runId, baseAt, actor] + ); + await client.query( + `INSERT INTO prediction_point + (run_id, forecast_at, elapsed_seconds, values_json, + tubing_pressure_psi, flow_rate_bpd, wellhead_temperature_f, + water_cut_percent, alarm_risk, triggered_rule_ids) + VALUES ($1, $2, 300, '{"flow_rate":100}', 1800, 100, 140, 20, + 'none', '[]')`, + [runId, forecastAt] + ); + }); + + const response = await request(`/twin/forecasts/${runId}`, { cookie }); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: { points: unknown[] } }; + expect(body.data.points).toHaveLength(1); + + await withDb((client) => + client.query('DELETE FROM prediction_point WHERE run_id = $1', [runId]) + ); + const expired = await request(`/twin/forecasts/${runId}`, { cookie }); + expect(expired.status).toBe(410); + }); + + it('loads the saved scenario and its unchanged baseline', async () => { + await withDb(async (client) => { + await client.query( + `INSERT INTO scenario_run + (run_id, created_at, wellhead_id, requested_by_user_id, name, + model_version, parameter_fingerprint, base_simulated_at, + horizon_seconds, step_seconds, base_state, parameter_values, + control_values, scheduled_controls, alarm_rules, + confidence_score, confidence_reasons) + VALUES ($1, $2, 1, $3, 'Close choke', '1.0.0', 'fingerprint', $2, + 300, 300, '{}', '{}', '{"choke_position_percent":100}', + '[{"offset_seconds":0,"change":{"choke_position_percent":80,"pump_on":null}}]', '[]', 0.5, '["Synthetic model"]')`, + [scenarioId, baseAt, actor] + ); + await client.query( + `INSERT INTO scenario_point + (run_id, forecast_at, elapsed_seconds, values_json, + tubing_pressure_psi, flow_rate_bpd, wellhead_temperature_f, + water_cut_percent, baseline_values_json, alarm_risk, + baseline_alarm_risk, triggered_rule_ids) + VALUES ($1, $2, 300, '{"flow_rate":0}', 2000, 0, 140, 20, + '{"flow_rate":100}', 'warning', 'none', '[]')`, + [scenarioId, forecastAt] + ); + }); + + const response = await request(`/twin/scenarios/${scenarioId}`, { cookie }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { + kind: string; + points: { + values: { flow_rate: number }; + baselineValues: { flow_rate: number }; + }[]; + }; + }; + expect(body.data.kind).toBe('scenario'); + expect(body.data.points[0]?.values.flow_rate).toBe(0); + expect(body.data.points[0]?.baselineValues.flow_rate).toBe(100); + }); + it('returns the saved schedule and canonical units without point queries in history', async () => { + const saved = await request(`/twin/scenarios/${scenarioId}`, { cookie }); + const result = await responseData(saved); + expect(result.baselineControls).toEqual({ choke_position_percent: 100 }); + expect(result.scheduledControls).toEqual([ + { offsetMinutes: 0, input: { chokePositionPercent: 80 } }, + ]); + expect(result.signals).toContainEqual( + expect.objectContaining({ code: 'flow_rate', canonicalUnit: 'bbl/day' }) + ); + const history = await request('/twin/wellheads/1/runs?kind=scenario&limit=1', { + cookie, + }); + expect(history.status).toBe(200); + const page = await responseData(history); + expect(page.items[0]?.runId).toBe(scenarioId); + expect(page.items[0]).not.toHaveProperty('points'); + expect(page.hasMore).toBe(false); + expect( + (await request('/twin/wellheads/1/runs?kind=scenario&offset=1', { cookie })) + .status + ).toBe(200); + const otherWell = await request('/twin/wellheads/2/runs?kind=scenario', { cookie }); + expect((await responseData(otherWell)).items).toEqual([]); + }); + + it('bounds history, validates the well, and requires authentication', async () => { + expect((await request('/twin/wellheads/1/runs')).status).toBe(401); + for (const query of [ + 'limit=51', + 'offset=-1', + 'offset=10001', + 'kind=invalid', + 'unexpected=1', + ]) { + expect( + (await request(`/twin/wellheads/1/runs?${query}`, { cookie })).status + ).toBe(400); + } + expect((await request('/twin/wellheads/2147483647/runs', { cookie })).status).toBe( + 404 + ); + }); + + it('keeps model lifecycle reads available during partial startup and strips extra fields', async () => { + const status = { + wellheadId: 1, + modelVersion: '0.3.0', + status: 'degraded', + reason: 'model_step_failed', + lastTickAt: baseAt, + simulatedAt: baseAt, + processAgeSeconds: 100, + current: false, + skippedTicks: 0, + restoredFrom: null, + lastRecoveryEvent: null, + internalSecret: 'must-not-pass', + }; + const call = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ checkedAt: baseAt, available: true, wellheads: [status] }) + ) + ); + try { + expect((await request('/twin/status')).status).toBe(401); + const response = await request('/twin/status', { cookie }); + expect(response.status).toBe(200); + const data = await responseData(response); + expect(data.wellheads[0]?.current).toBe(false); + expect(data.wellheads[0]).not.toHaveProperty('internalSecret'); + expect(String(call.mock.calls[0]?.[0])).toContain('/model-status'); + } finally { + call.mockRestore(); + } + }); + + it('reports an unavailable model without failing the optional status panel', async () => { + const call = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('unreachable')); + try { + const response = await request('/twin/status', { cookie }); + expect(response.status).toBe(200); + expect(await responseData(response)).toMatchObject({ + available: false, + wellheads: [], + }); + } finally { + call.mockRestore(); + } + }); + + it('paginates metadata even when a run has no retained points', async () => { + const secondId = randomUUID(); + await withDb((client) => + client.query( + `INSERT INTO scenario_run + SELECT $1, created_at + interval '1 second', wellhead_id, requested_by_user_id, + name, model_version, parameter_fingerprint, base_simulated_at, horizon_seconds, + step_seconds, base_state, parameter_values, control_values, scheduled_controls, + alarm_rules, confidence_score, confidence_reasons FROM scenario_run WHERE run_id=$2`, + [secondId, scenarioId] + ) + ); + try { + const first = await responseData( + await request('/twin/wellheads/1/runs?kind=scenario&limit=1', { cookie }) + ); + expect(first.items.map((item: { runId: string }) => item.runId)).toEqual([ + secondId, + ]); + expect(first.hasMore).toBe(true); + const second = await responseData( + await request('/twin/wellheads/1/runs?kind=scenario&limit=1&offset=1', { + cookie, + }) + ); + expect(second.items.map((item: { runId: string }) => item.runId)).toEqual([ + scenarioId, + ]); + expect(second.hasMore).toBe(false); + expect((await request(`/twin/scenarios/${secondId}`, { cookie })).status).toBe( + 410 + ); + } finally { + await withDb((client) => + client.query('DELETE FROM scenario_run WHERE run_id=$1', [secondId]) + ); + } + }); + + it('includes configured wells without readings in the fleet overview', async () => { + const expected = await withDb((client) => + client.query('SELECT wellhead_id FROM wellhead') + ); + const response = await request('/dashboard/overview', { cookie }); + expect(response.status).toBe(200); + const body = await responseData(response); + expect(body.wellheads).toHaveLength(expected.rows.length); + expect( + body.wellheads.every((asset: { activeAlarmCount: number }) => + Number.isInteger(asset.activeAlarmCount) + ) + ).toBe(true); + }); + it('authenticates strict snapshot searches and preserves exact identifiers', async () => { + const at = new Date().toISOString(); + const path = `/twin/wellheads/1/snapshots?${new URLSearchParams({ at })}`; + expect((await request(path)).status).toBe(401); + expect((await request(path + '&extra=1', { cookie })).status).toBe(400); + expect( + (await request('/twin/wellheads/1/snapshots?at=invalid', { cookie })).status + ).toBe(400); + const payload = { + wellheadId: 1, + checkedAt: at, + hasMore: false, + items: [ + { + snapshotId: '9007199254740993', + simulatedAt: at, + modelVersion: '0.3.0', + compatible: true, + reason: 'compatible', + }, + ], + }; + const call = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(payload))); + try { + const response = await request(path, { cookie }); + expect(response.status).toBe(200); + expect(await responseData(response)).toEqual(payload); + const headers = new Headers(call.mock.calls[0]?.[1]?.headers); + expect(headers.get('x-twin-internal-token')?.length).toBeGreaterThanOrEqual(32); + call.mockResolvedValueOnce( + new Response(JSON.stringify({ ...payload, wellheadId: 2 })) + ); + expect((await request(path, { cookie })).status).toBe(503); + } finally { + call.mockRestore(); + } + }); + + it('keeps historical provenance in saved detail and history after source expiry', async () => { + const origin = { + kind: 'snapshot', + snapshotId: '9007199254740993', + simulatedAt: baseAt, + }; + await withDb((client) => + client.query('UPDATE scenario_run SET base_state=$1 WHERE run_id=$2', [ + { branchOrigin: origin }, + scenarioId, + ]) + ); + try { + const saved = await responseData( + await request(`/twin/scenarios/${scenarioId}`, { cookie }) + ); + expect(saved.branchOrigin).toEqual(origin); + const history = await responseData( + await request('/twin/wellheads/1/runs?kind=scenario', { cookie }) + ); + expect( + history.items.find((item) => item.runId === scenarioId)?.branchOrigin + ).toEqual(origin); + // No source snapshot exists: saved reads depend on the run's own base record. + } finally { + await withDb((client) => + client.query("UPDATE scenario_run SET base_state='{}' WHERE run_id=$1", [ + scenarioId, + ]) + ); + } + }); + + it('passes snapshot identity unchanged and rejects a mismatched upstream source', async () => { + const selection = { snapshotId: '9007199254740993', simulatedAt: baseAt }; + const input = { + wellheadId: 1, + name: 'Past pressure', + horizonSeconds: 300, + stepSeconds: 300, + controls: [{ offsetMinutes: 0, input: { pumpOn: false } }], + baseSnapshot: selection, + }; + const payload = { + runId, + kind: 'scenario', + wellheadId: 1, + name: input.name, + source: 'synthetic_reduced_order_model', + fieldCalibrated: false, + modelVersion: '0.3.0', + baseSimulatedAt: baseAt, + generatedAt: baseAt, + horizonSeconds: 300, + stepSeconds: 300, + branchOrigin: { kind: 'snapshot', ...selection }, + confidence: { + method: 'heuristic_uncalibrated', + score: 0.4, + label: 'moderate', + reasons: ['historical_snapshot'], + }, + points: [ + { + elapsedSeconds: 300, + timestamp: forecastAt, + values: { flow_rate: 0 }, + baselineValues: { flow_rate: 100 }, + alarmRisk: 'none', + baselineAlarmRisk: 'none', + triggeredRuleIds: [], + }, + ], + }; + const call = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(payload), { status: 201 })); + try { + expect( + (await request('/twin/scenarios', { method: 'POST', cookie, body: input })) + .status + ).toBe(200); + expect(JSON.parse(String(call.mock.calls[0]?.[1]?.body)).baseSnapshot).toEqual( + selection + ); + call.mockResolvedValueOnce( + new Response( + JSON.stringify({ ...payload, branchOrigin: { kind: 'current' } }), + { status: 201 } + ) + ); + expect( + (await request('/twin/scenarios', { method: 'POST', cookie, body: input })) + .status + ).toBe(502); + call.mockResolvedValueOnce(new Response('{}', { status: 409 })); + expect( + (await request('/twin/scenarios', { method: 'POST', cookie, body: input })) + .status + ).toBe(409); + expect( + ( + await request('/twin/wellheads/1/forecast', { + method: 'POST', + cookie, + body: { baseSnapshot: selection }, + }) + ).status + ).toBe(400); + } finally { + call.mockRestore(); + } + }); +}); diff --git a/monorepo/apps/api/tsconfig.json b/monorepo/apps/api/tsconfig.json index 1ac9533..6eea824 100644 --- a/monorepo/apps/api/tsconfig.json +++ b/monorepo/apps/api/tsconfig.json @@ -1,21 +1,13 @@ { "extends": "@well-simulation/tsconfig/base.json", + "include": ["src", "tests", "tsup.config.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist"], "compilerOptions": { - "strict": true, - "plugins": [ - { - "name": "next" - } - ], - "lib": ["dom", "dom.iterable", "esnext", "webworker"], - "allowJs": true, "noEmit": true, - "incremental": true, "module": "esnext", - "resolveJsonModule": true, - "jsx": "preserve", - "target": "ES2017" - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "public/sw.js"] + "moduleResolution": "bundler", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"] + } } diff --git a/monorepo/apps/website/.gitignore b/monorepo/apps/website/.gitignore index 58a2e3f..cc0b665 100644 --- a/monorepo/apps/website/.gitignore +++ b/monorepo/apps/website/.gitignore @@ -23,3 +23,7 @@ dist-ssr *.sln *.sw? .react-router + +# Browser verification artifacts. +test-results/ +playwright-report/ diff --git a/monorepo/apps/website/Dockerfile b/monorepo/apps/website/Dockerfile index acbef7e..1d8bc06 100644 --- a/monorepo/apps/website/Dockerfile +++ b/monorepo/apps/website/Dockerfile @@ -25,12 +25,27 @@ RUN pnpm install --frozen-lockfile COPY . . RUN pnpm --filter @well-simulation/website exec react-router build -FROM nginx:1.27-alpine AS runner +# Fill the CSP with hashes of this build's inline startup scripts. Fails the +# image build if they cannot be found. +RUN node apps/website/scripts/csp-inline-script-hashes.mjs \ + apps/website/build/client/index.html \ + apps/website/nginx.conf \ + /app/nginx.conf -COPY apps/website/nginx.conf /etc/nginx/conf.d/default.conf +# nginx 1.27.5, including dynamic upstream DNS support added in 1.27.3. +FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runner + +COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf RUN rm -rf /usr/share/nginx/html/* COPY --from=builder /app/apps/website/build/client /usr/share/nginx/html +RUN nginx -t + +# Readiness includes the route used by the browser and the API's database check. +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -T 2 -O /dev/null http://127.0.0.1/ \ + && wget -q -T 2 -O /dev/null http://127.0.0.1/api/ready || exit 1 + EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] diff --git a/monorepo/apps/website/app/assets/images/auth/auth-bg.webp b/monorepo/apps/website/app/assets/images/auth/auth-bg.webp deleted file mode 100644 index f62a245..0000000 Binary files a/monorepo/apps/website/app/assets/images/auth/auth-bg.webp and /dev/null differ diff --git a/monorepo/apps/website/app/domains/alarms/cards.tsx b/monorepo/apps/website/app/domains/alarms/cards.tsx new file mode 100644 index 0000000..1244707 --- /dev/null +++ b/monorepo/apps/website/app/domains/alarms/cards.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react'; +import { Link, useRevalidator } from 'react-router'; +import type { + AlarmEvent, + AlarmReceipt, + AlarmShelfList, +} from '@well-simulation/dto/res/alarms'; +import { timeText } from '../twin/presentation'; +import { eventDetail, alarmError } from './route'; +import { + eventReview, + shelfReview, + shelfSeconds, + reviewKey, + type AlarmReview, +} from './review'; + +const valueText = (value: number) => + new Intl.NumberFormat('en', { maximumFractionDigits: 2 }).format(value); +export const receiptText = (receipt: AlarmReceipt) => + `${receipt.actorName} ${receipt.action === 'acknowledge' ? 'acknowledged the event' : receipt.action === 'shelve' ? 'shelved the rule' : 'ended the shelf'} for ${receipt.target.wellheadName}, ${receipt.target.parameterName}.`; +export function EventCard({ + event, + canOperate, + verified, + observedAt, + elapsed, + onReview, +}: { + event: AlarmEvent; + canOperate: boolean; + verified: boolean; + observedAt: string; + elapsed: number; + onReview: (value: AlarmReview) => void; +}) { + const [history, setHistory] = useState + > | null>(null); + const [error, setError] = useState(null), + [busy, setBusy] = useState(false); + const refresh = useRevalidator(); + const review = eventReview(event), + remaining = shelfSeconds(review, observedAt, elapsed); + const title = `event-${event.eventId}-${event.triggeredAt.replace(/[^0-9]/g, '')}`; + return ( +
+
+
+ + {event.severity} + + {event.clearedAt ? 'Returned to normal' : 'Active condition'} + + {event.acknowledgement ? 'Acknowledged' : 'Not acknowledged'} + + {event.shelf?.active && ( + + {remaining !== null && remaining <= 0 + ? 'Shelf deadline reached' + : 'Shelved'} + + )} +
+
+

+ {event.target.wellheadName} · {event.target.parameterName} +

+

{event.target.locationName}

+
+
+
+
Value when triggered
+
+ {valueText(event.triggeredValue)} {event.target.unit} +
+
+
+
Current rule threshold
+
+ {event.target.operator} {valueText(event.target.threshold)}{' '} + {event.target.unit} +
+
+
+
Triggered
+
{timeText(event.triggeredAt)}
+
+ {event.clearedAt && ( +
+
Returned to normal
+
{timeText(event.clearedAt)}
+
+ )} +
+ {event.acknowledgement && ( +

+ Acknowledged by {event.acknowledgement.actorName},{' '} + {timeText(event.acknowledgement.at)}. Reason: {event.acknowledgement.reason} +

+ )} + {event.shelf?.active && ( +

+ {remaining !== null && remaining <= 0 + ? 'The shelf deadline has passed. Refresh to verify its current state.' + : `Shelved until ${timeText(event.shelf.until!)}.`}{' '} + {event.shelf.actorName}: {event.shelf.reason} +

+ )} +
+ Event details and recent actions +

+ Event {event.eventId}, {event.triggeredAt}. +

+

+ The current rule opens after {event.target.triggerAfter} consecutive + breaches and clears after {event.target.clearAfter} normal readings. The + event stores its trigger value and severity; it does not store the threshold + that applied at that time. +

+ + {error && ( +

+ {error} +

+ )} + {history && ( + <> +

+ Latest 20 actions for this well and rule, checked{' '} + {timeText(history.observedAt)}. +

+ {history.actions.length === 0 ? ( +

No operator actions recorded for this rule.

+ ) : ( +
    + {history.actions.map((item) => ( +
  1. +

    + {receiptText(item)} {timeText(item.occurredAt)} +

    +

    + Reason: {item.reason} + {item.shelvedUntil + ? `. Deadline: ${timeText(item.shelvedUntil)}` + : ''} +

    +
  2. + ))} +
+ )} + + )} +
+
+ + Open well + + {canOperate && ( + + )} +
+
+
+ ); +} +export function ShelfCard({ + item, + canOperate, + verified, + observedAt, + elapsed, + onReview, +}: { + item: AlarmShelfList['items'][number]; + canOperate: boolean; + verified: boolean; + observedAt: string; + elapsed: number; + onReview: (value: AlarmReview) => void; +}) { + const review = shelfReview(item), + remaining = shelfSeconds(review, observedAt, elapsed); + return ( +
+
+ Shelved rule +

+ {item.target.wellheadName} · {item.target.parameterName} +

+

+ {remaining !== null && remaining <= 0 + ? 'The deadline has passed. Refresh to verify its current state.' + : `Shelf ends ${timeText(item.state.until!)}.`} +

+

+ {item.state.actorName}: {item.state.reason} +

+

+ This shelf applies to new events for this well and rule, even if no condition + is active now. Revision {item.state.revision}. +

+
+ + Open well + + {canOperate && ( + + )} +
+
+
+ ); +} diff --git a/monorepo/apps/website/app/domains/alarms/editor.tsx b/monorepo/apps/website/app/domains/alarms/editor.tsx new file mode 100644 index 0000000..7d441de --- /dev/null +++ b/monorepo/apps/website/app/domains/alarms/editor.tsx @@ -0,0 +1,323 @@ +import { useEffect, useState } from 'react'; +import { useFetcher, useRevalidator } from 'react-router'; +import { + alarmActionInputSchema, + SHELF_MIN_MINUTES, + SHELF_MAX_MINUTES, + type AlarmActionInput, +} from '@well-simulation/dto/req/alarms'; +import type { AlarmReceipt } from '@well-simulation/dto/res/alarms'; +import { numberText, timeText } from '../twin/presentation'; +import type { alarmPageAction } from './route'; +import { reviewVersion, shelfSeconds, type AlarmReview } from './review'; + +export function AlarmEditor({ + selected, + current, + verified, + observedAt, + elapsed, + onDone, + onCancel, +}: { + selected: AlarmReview; + current: AlarmReview | null; + verified: boolean; + observedAt: string; + elapsed: number; + onDone: (receipt: AlarmReceipt) => void; + onCancel: () => void; +}) { + const fetcher = useFetcher>>(); + const refresh = useRevalidator(); + const [baseline, setBaseline] = useState(selected); + const [action, setAction] = useState( + selected.event && !selected.event.acknowledgement + ? 'acknowledge' + : selected.shelf?.active + ? 'unshelve' + : 'shelve' + ); + const [reason, setReason] = useState(''); + const [duration, setDuration] = useState(30); + const [confirmed, setConfirmed] = useState(false); + const [pending, setPending] = useState<{ + key: string; + input: AlarmActionInput; + } | null>(null); + const [uncertain, setUncertain] = useState(false); + const [missing, setMissing] = useState(false); + const [message, setMessage] = useState(null); + const busy = fetcher.state !== 'idle'; + const changed = !current || reviewVersion(current) !== reviewVersion(baseline); + const remaining = current ? shelfSeconds(current, observedAt, elapsed) : null; + const canAcknowledge = !!baseline.event && !baseline.event.acknowledgement; + const canUnshelve = !!baseline.shelf?.active && remaining !== null && remaining > 0; + const disabled = !verified || busy || uncertain || changed; + useEffect(() => { + const result = fetcher.data; + if (!result || busy) return; + if (result.receipt) { + onDone(result.receipt); + return; + } + setUncertain(result.uncertain); + setMissing(result.missing); + setMessage( + result.missing + ? 'No committed result was found yet. You can check again or retry the same reviewed request.' + : (result.error?.message ?? null) + ); + if (!result.uncertain) setConfirmed(false); + document.getElementById('alarm-editor-message')?.focus({ preventScroll: true }); + }, [fetcher.data, busy, onDone]); + const submit = (request: NonNullable, operation = 'write') => + fetcher.submit( + { operation, requestKey: request.key, input: JSON.stringify(request.input) }, + { method: 'post' } + ); + const reviewCurrent = () => { + if (!current) { + refresh.revalidate(); + return; + } + setBaseline(current); + setConfirmed(false); + setMessage(null); + setPending(null); + if (action === 'acknowledge' && current.event?.acknowledgement) + setAction(current.shelf?.active ? 'unshelve' : 'shelve'); + if (action === 'unshelve' && !current.shelf?.active) setAction('shelve'); + }; + return ( +
+
+

+ Review action for {baseline.target.wellheadName} +

+
+
+

+ {baseline.target.parameterName}. Current rule: {baseline.target.operator}{' '} + {numberText(baseline.target.threshold)} {baseline.target.unit}. +

+ {baseline.event && ( +

+ Event triggered {timeText(baseline.event.triggeredAt)}. Condition:{' '} + {baseline.event.clearedAt ? 'returned to normal' : 'active'}. +

+ )} + {baseline.shelf?.active && ( +

+ Current shelf ends {timeText(baseline.shelf.until!)}. Setting another shelf + replaces that deadline. +

+ )} + {message && ( +

+ {message} +

+ )} + {!verified && ( +

+ Refresh the alarm state before submitting an action. +

+ )} + {changed && !uncertain && ( +
+

+ The reviewed state changed. Your reason remains below.{' '} + {current + ? 'Review the current state before continuing.' + : 'Refresh this view and select the current event or shelf.'} +

+ {current && ( + + )} +
+ )} + {uncertain && pending && ( +
+

+ Keep this review open until you check the outcome. A missing reply does + not prove the action failed. +

+
+ + {missing && ( + + )} +
+
+ )} +
{ + event.preventDefault(); + if (disabled) return; + const parsed = alarmActionInputSchema.safeParse( + action === 'acknowledge' + ? { + action, + eventId: baseline.event?.eventId, + triggeredAt: baseline.event?.triggeredAt, + reason, + confirmed, + } + : { + action, + wellheadId: baseline.target.wellheadId, + ruleId: baseline.target.ruleId, + expectedRevision: baseline.shelf?.revision ?? 0, + ...(action === 'shelve' ? { durationMinutes: duration } : {}), + reason, + confirmed, + } + ); + if (!parsed.success) { + setMessage( + 'Check the reason, duration and confirmation before submitting.' + ); + return; + } + const request = { key: crypto.randomUUID(), input: parsed.data }; + setPending(request); + setMissing(false); + setMessage(null); + submit(request); + }} + > +
+ Reviewed alarm action +
+ + +
+

+ {action === 'acknowledge' + ? 'This records that you have seen this event. Normal readings clear its condition.' + : action === 'shelve' + ? 'This removes this well and rule from Needs acknowledgement until the deadline, including any new events. All active totals still count the condition.' + : 'This ends the shelf now. Unacknowledged events return to Needs acknowledgement.'} +

+ {action === 'shelve' && ( +
+ + { + setDuration(Number(e.target.value)); + setConfirmed(false); + }} + /> + + Choose {SHELF_MIN_MINUTES}–{SHELF_MAX_MINUTES} whole minutes. The + deadline starts when the database records the action. + +
+ )} + {action === 'unshelve' && !canUnshelve && ( +

+ The shelf deadline has passed. Refresh to verify its current state. +

+ )} +
+ +