Skip to content

CONS-A3: P1AM plant historian + professional SCADA foundation (supersedes #4065, #4091) - #4449

Open
dieterolson wants to merge 44 commits into
mainfrom
consolidated/p1am-platform-2026-08-13
Open

CONS-A3: P1AM plant historian + professional SCADA foundation (supersedes #4065, #4091)#4449
dieterolson wants to merge 44 commits into
mainfrom
consolidated/p1am-platform-2026-08-13

Conversation

@dieterolson

@dieterolson dieterolson commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

CONS-A3. Consolidates the two open P1AM platform PRs into one merge boundary.

Supersedes #4065 (feat/timescale-historian) and #4091 (agent/scada-phase-a-foundation).

Containment verified by ancestry, not by title: verify_coverage.sh reports
covered=2 NOT-covered=0 against this branch, so closing both drops nothing.

What is in here

From Content
#4065 Level 3/4 plant historian: HistorianSink protocol, HistorianWriter, StoreAndForwardSink, TimescaleDB writer, 6 versioned SQL migrations, Grafana provisioning, ADR-007
#4091 Professional SCADA foundation F01–F16: named identity/RBAC/audit, qualified signals, alarm lifecycle, configuration approval/rollback, recovery + health centre, operator overview/faceplates, interlock/first-out/bypass, historian investigations, asset health, shift log, procedures, connector plugins, notification/escalation, availability contracts, review-only advisory workspace

131 files, +16152/-246, all under src/p1am_control_system/ except 2 ADRs,
SPEC.md, and docs/development/professional-scada-epic.md.

The one real integration defect, and how it was resolved

Both PRs rewrote backend/main.py::_throttled_log_scan, and neither side is
correct alone
:

Taking #4065's side would raise TypeError inside the running scan loop;
taking #4091's side silently drops the entire historian forwarding path.

Resolved by threading signal_frame through HistorianWriter.write to the local
write. The remote sink contract stays {tag: value} + timestamp, so no sink
implementation gains a dependency on the quality model. Three regression tests
added in test_historian_sink.py (forwards the frame; defaults to None for
two-argument callers; the sink never receives it).

Defects found and fixed while consolidating

  1. Python 3.10 breakage (12 + 1 files). feat(scada): deliver professional representative SCADA product #4091 added 12 test modules with a
    bare module-scope from datetime import UTC. That name does not exist on
    Python 3.10 and the test matrix still runs 3.10, so all 12 fail to import
    there. Replaced with the guarded try/except ImportError shim already used
    throughout this package. Separately, feat(p1am): plant historian and analytics platform (TimescaleDB + Grafana) #4065's pre-commit pass line-wrapped a
    datetime(...) call in tests/unit/sidekick/agent/test_action_audit.py,
    displacing its # noqa: UP017 onto the closing paren; ruff then applied UP017
    and introduced the same bare import. Restored main's form.

  2. quality-gate Format Check would have failed. feat(scada): deliver professional representative SCADA product #4091 left
    test_auth_config.py unformatted (it is formatted on main) and added
    test_identity_config.py unformatted. Both reformatted with the CI-pinned
    ruff 0.14.10.

  3. Committed gitlinks. Six 160000 entries under .codex-worktrees/,
    inherited by both PRs. origin/main has none, no .gitmodules declares them,
    and the commits they reference exist on no remote — a fresh clone would get
    six dangling gitlinks. Removed. (.gitignore deliberately untouched; that
    line is owned by CONS-A1 CONS-A1: CI/infra consolidation (file-size-budget checkout, pdf_renamer SQLite leak, fleet-load fixes) #4445.)

  4. dcs_scada.db committed at the repo root by feat(p1am): plant historian and analytics platform (TimescaleDB + Grafana) #4065 — a 4 KB SQLite runtime
    artifact. .gitignore covers
    src/p1am_control_system/backend/dcs_scada.db but not the root copy, which is
    what database.py's relative DB_FILE = "dcs_scada.db" produces when run
    from the repo root. Nothing reads it; tests build their own databases. Removed.

    Independently corroborated: fix(p1am): anchor historian DB to package dir; ignore stray dcs_scada.db #4068 (fix/p1am-db-path-anchoring, carried by
    CONS-A2 P1AM SCADA safety batch: de-energize on fault (consolidates 11 PRs, 46 issues) #4448) diagnoses exactly this — a bare relative sqlite:///dcs_scada.db
    resolving against the process CWD, with path-anchored .gitignore entries that
    miss the root copy — and says "this nearly landed in SCADA: fix poll-loop data integrity, cadence and backpressure (#4004 #4008 #4009 #4023 #4024) #4064". It in fact landed
    in feat(p1am): plant historian and analytics platform (TimescaleDB + Grafana) #4065, and this PR removes it. The two changes are complementary and do not
    conflict: fix(p1am): anchor historian DB to package dir; ignore stray dcs_scada.db #4068 fixes the cause (absolute path anchored to the backend package
    dir, plus unanchored .gitignore patterns), this PR removes the artifact that
    was already committed. The .gitignore change is left to fix(p1am): anchor historian DB to package dir; ignore stray dcs_scada.db #4068 rather than
    duplicated here.

  5. A stale-base revert was caught and not propagated. feat(p1am): plant historian and analytics platform (TimescaleDB + Grafana) #4065's
    frontend/src/lib/tabs.ts reintroduces .reduce()/.map() in
    defaultTabVisibility and defaultTabOrder, silently undoing the Bolt
    single-pass optimisation main gained from ⚡ Bolt: Replace map and reduce array methods with single-pass loops in default tab initializers #4176 after that branch forked.
    The merge resolved toward main, so HEAD keeps both single-pass loops and adds
    only feat(scada): deliver professional representative SCADA product #4091's operator tab. Verified explicitly rather than assumed.

  6. ~290 files of inherited formatting churn reverted. Both branches carried
    repo-wide pre-commit commits produced by a ruff build that disagrees with the
    CI-pinned 0.14.10 — 90 changed .py files failed ruff format --check, the
    same red-gate mode that blocked 🎨 Palette & Bolt Suite: Form Submission, Accessibility, SVG & CSV Optimizations #4429/Codex Flight Suite: Consolidated Agent Flight and Metric Models #4437/Codex Rate of Closure Suite: Consolidated Agent Camera, Attribution and Execution Workflows #4439. The churn also displaced
    # noqa comments (9 live F841 errors in
    tests/unit/sidekick/test_sidekick_ux_hardening.py) and reformatted
    src/pendulum_simulator/, src/movement_optimizer/ and
    src/data_processing/, which the CI format gate excludes and repo policy says
    not to touch. Reverted by provenance to main's reviewed content. This also
    removes the churn STATE.md predicted would collide with CONS-B1: Palette & Bolt Suite (form submission, accessibility, SVG & CSV optimizations) + movement-optimizer motion-view extraction #4438.

Verification

  • ruff check and ruff format --check (pinned 0.14.10) on all 88
    CI-gated changed .py files: clean, replicating ci-standard.yml's exact
    grep -v exclude list.
  • P1AM backend suite: 1087 passed, 6 skipped (the 6 are CI-only
    dependency-presence gates).
  • git grep "<<<<<<< HEAD" empty; no bare from datetime import UTC; zero
    mode-160000 entries.
  • Feature spot-check against the epics' F01–F16 claims: a real module with a
    paired test exists for every feature — identity (252 LOC/153 test),
    signal_quality (217/91), alarm_lifecycle (326/132),
    configuration_workflow (331/144), recovery_package (187/109),
    system_health (246/74), process_overview (151/56),
    protection_management (168/130), saved_investigation (268/143),
    synthetic_procedure (145/78), asset_health (215/131),
    connector_plugins (198/88), scenario_evidence (317/125), shift_log
    (237/126), notification_policy (178/116), availability (188/78),
    advisory_workspace (359/97). No NotImplementedError, TODO, FIXME or
    placeholder stub in any of them.

Issue references — read carefully

Closes #4047, #4048, #4049, #4050, #4051, #4052, #4054, #4055, #4056.
Closes #4085, #4086, #4087, #4088.

Part of #4046.
Part of #4089 — the parent SCADA epic stays open; F01–F16 landing does not
close the epic's own verification gates.

This PR does NOT fix #3973, and does not claim to

#3973 (P0: NaN clears active alarms) is untouched. Verified directly:

  • backend/main.py binds AlarmEngine = scada.AlarmEngine from
    scada_fallback, so the pure-Python fallback is the live engine whenever the
    tools_core wheel is absent.
  • scada_fallback.py contains no isnan/isfinite guard.
  • Neither scada_fallback.py nor rust_core/tools-core/src/scada.rs is modified
    by this PR.

#4091 does add a NaN-safe alarm_lifecycle.py (math.isfinite at two
boundaries), but that is a new parallel module, not the engine bound to
AlarmEngine. #3973 remains open and unclaimed.

#4047 needs no amendment — it already has one

#4065 documents that it deliberately did not build the specced
SqliteHistorianSink, because a sink owning its own session would have broken
the shared tag+alarm commit in _poll_once. #4047's body already carries a
"Revised after implementation" note recording exactly that rejection, and its
acceptance criteria now describe what was actually built. So Closes #4047 is
legitimate rather than a silent AC substitution.

One caveat is posted as a comment on #4047: its amended AC "poll_runtime.py
unchanged — control path has no diff"
does not survive this consolidation,
because #4091 legitimately changes the control path to carry signal_frame. The
substantive guarantee — sinks are forwarding-only and can never affect local
durability or the shared commit — does survive, and is asserted by test.

Prior-work invariants re-checked on this branch

  • Historian is throttled to 5 s, not 10 Hz: capture_interval_s defaults to
    5.0 in settings.py.
  • The historian sits above the control path: forwarding is off by default,
    the shipper is a thread doing only put_nowait, and all forwarding exceptions
    are contained.
  • The sink stays forwarding-only, so _poll_once's shared tag+alarm commit
    survives — the local write remains on the caller's session and HistorianWriter
    never commits.
  • Retention SQL still carries the continuous-aggregate caveat:
    timescale/README.md documents that 004_retention.sql deletes data and must
    not run before 002's aggregates have materialised, with the verification
    query and the WITH NO DATA backfill step. Note this is mitigated by runbook
    discipline, not by code
    — the migrations are operator-applied and never run
    automatically.

Reviewer note

quality-gate is the fast hosted gate. tests (3.11) runs on the 7-runner
self-hosted fleet behind a pick-runner job; a tests (3.11) check that has not
been created yet means pick-runner is still queued — that is a queue, not a
failure.


Two things a reviewer should explicitly accept or reject

1. config/module_size_budget_baseline.json: backend/main.py 1440 → 1708.
quality-gate step 24 "Module Size Budget" is a ratchet — tracked oversized
modules are frozen at their current size and any growth fails. main.py grew
1440 → 1480 with #4065 (historian wiring) → 1667 with #4091 (router registration
and new endpoints) → 1708 combined. The number was raised rather than the module
refactored, because splitting a 1708-line FastAPI application module is
substantial independent work with real regression risk and would confuse the
provenance of a consolidation whose job is to preserve two PRs' content. This
is a deliberate loosening of a quality gate and it is legitimate to reject it and
ask for extraction first.
Most of the growth is router registration and
endpoint bodies that belong in the *_router.py modules #4091 already
established.

2. The signal_frame seam is new code written during consolidation. Neither
original PR contains it; it exists because the two are individually incompatible
(see above). It is small, documented, and covered by three regression tests, but
it has had no prior review.

mypy note for anyone re-verifying locally on Windows

ci-standard.yml exports MYPYPATH=src:src/python/src. The : separator is
correct on Linux but on Windows the separator is ;, so that string parses as a
single nonexistent directory, every first-party import resolves to Any,
--ignore-missing-imports swallows it, and mypy reports success while checking
almost nothing.
The check here was run as CI runs it otherwise — the same 46
changed non-test files after CI's legacy filter, in one invocation via
@listfile (no xargs, which splits long lists on Windows and destroys
resolution), MYPYPATH='src;src/python/src', numpy pinned <2.4, and mypy
1.13.0 (CI's pin, not the locally installed 2.1.0): Success: no issues found in 46 source files.

dieterolson and others added 30 commits July 31, 2026 19:09
Adds a Level 3 plant-historian forwarding path above the control system.
Off by default; SQLite remains the local source of truth and the control
path is unchanged.

Closes #4047, #4048, #4049, #4050, #4051. Part of #4046.

Design note: the epic originally specced the SQLite sink owning its own
session. That would have broken the shared commit in _poll_once, where
historian rows and alarm events are committed together so a scan is atomic
in the local DB. Instead the local write stays exactly where it is and
HistorianSink is a forwarding-only interface, so a remote historian cannot
affect local durability or transactional behaviour.

- historian_sink: HistorianSink protocol, NullHistorianSink, HistorianWriter.
  Matches the Callable[[Session, dict], int] shape _poll_once already accepts,
  so no control-path change was needed. Local write first, forward after, all
  forwarding exceptions swallowed. Returns the local row count so callers
  cannot confuse "historian unreachable" with "not recorded".

- historian_shipper: StoreAndForwardSink. Bounded queue, non-blocking
  put_nowait, drop-oldest on overflow, daemon worker owning all socket I/O,
  exponential backoff with jitter, rate-limited logging, bounded shutdown
  flush. At-most-once by design and documented as such.

- timescale_writer: psycopg imported lazily so a bench Pi without a Postgres
  driver still boots. COPY-based batch insert, tag-name to surrogate-id
  resolution, DSN password redaction applied at every log site.

- timescale/*.sql: hypertable, compress_segmentby=tag_id, 1m and hierarchical
  1h continuous aggregates carrying min/max/sum/count, retention policies that
  downsample rather than delete, event_log, and least-privilege roles.

- settings: P1AM_TIMESCALE_* with a model validator that rejects enabled-with-
  empty-DSN at startup rather than silently forwarding nowhere.

- /api/historian/shipper exposes queue depth, lag, and drop counters so a gap
  in a trend can be identified as a forwarding gap rather than misread as a
  real process measurement.

Tests: 68 new. Full backend suite 864 passed, 6 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #4052, #4054, #4055, #4056. Part of #4046.

Deployment (separate host from the control Pi — co-locating TimescaleDB and
Grafana with the 10 Hz scan loop causes overruns):

- deploy/historian/docker-compose.yml: pinned TimescaleDB 2.17.2-pg16 and
  Grafana OSS 11.4.0, named volumes, healthcheck, Postgres tuned for a
  dedicated historian host. Postgres bound to loopback by default; migrations
  mounted read-only and deliberately NOT wired to docker-entrypoint-initdb.d so
  a container restart can never re-run DDL against a populated database.
- deploy/historian/.env.example: no committed secrets; compose fails fast on
  unset required vars.

Grafana as code (dashboards live in git, not Grafana's database —
allowUiUpdates false, datasource editable false):

- Read-only grafana_ro datasource, credentials from environment.
- alarm-performance.json — EEMUA 191 / ISA-18.2: alarm rate vs the <6/hour
  target, 10-minute flood windows, % time in flood, priority distribution,
  top-10 bad actors with % of load, chattering detection, standing alarms with
  no acknowledge in 24 h, and a panel for alarms raised while the tag had no
  finite sample (detection control for NaN-driven alarm state).
- process-overview.json — resolution selector across raw/1m/1h with an explicit
  note that an empty chart means wrong selector, not an idle plant; min/max
  envelope panel so excursions are not averaged away; asset-context table.
- campaign-comparison.json — golden-batch overlay via a time-shifted reference
  series plus a mean/min/max delta table.
- historian-health.json — ingest lag, sample rate, stale tags, compression
  ratio, and continuous-aggregate job status.

Design correction vs #4051 as specced: the issue assumed Grafana would read the
shipper counters from the Pi's /api/historian/shipper. Grafana OSS cannot scrape
an arbitrary HTTP endpoint without an external plugin, so ingest health is
measured at the destination instead. That is strictly better — it detects
shipper outages, network partitions, and a stopped control node alike. The API
endpoint remains for direct diagnosis.

Docs:

- ADR-007: why TimescaleDB over Influx/VictoriaMetrics/QuestDB/Prometheus, why
  not Ignition (and when to revisit), and the licensing constraints stated
  plainly rather than buried — Grafana is AGPLv3, and Timescale compression and
  continuous aggregates are TSL (source-available, not OSI-open).
- deploy/historian/README.md: runbook with first-time setup, end-to-end
  verification, troubleshooting, backup, and a one-variable rollback. Documents
  at-most-once delivery explicitly so nothing is built on an exactly-once
  assumption, and flags that Grafana OSS has no per-dashboard RBAC.
- USER_MANUAL.md section 11, written for an operator: Grafana is not the HMI
  and cannot control anything; the HMI is authoritative on disagreement; and a
  flat line may be a recording gap rather than a quiet process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Part of #4046.

The backend uses flat intra-package imports (`from historian_wiring import ...`),
which mypy resolves correctly when run from the backend directory but treats as
Any when run from the repo root — which is where both the pre-push hook and CI
invoke it. Under `warn_return_any` that turned two correct returns into
no-any-return errors, and under `warn_unused_ignores` it made the deliberate
`# type: ignore[arg-type]` comments in the DbC tests redundant.

- main.py: annotate the two locals rather than relying on cross-module
  resolution, so the check is honest from either working directory.
- tests: route deliberately-invalid arguments through an `Any`-typed local
  instead of suppressing the error with a comment. The intent ("this argument is
  wrong on purpose") is now expressed in code that is correct under both
  resolutions, rather than in a suppression that is only correct under one.

No behaviour change. mypy clean on all 9 changed files; 68 historian tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…runbook

Part of #4046. CI `detect-secrets` flagged 3 "Basic Auth Credentials".

All three were placeholders, but two of them pointed at something worth fixing
rather than merely silencing:

- `deploy/historian/README.md` setup step put the historian_admin password
  directly on the `psql` command line. Anything in argv is visible in `ps` and
  lands in shell history, so this now prompts into `PGPASSWORD` instead. Better
  practice independent of the scanner.
- The shipper DSN genuinely must carry its password inline — it is the single
  value the backend reads — so that one keeps an allowlist pragma and gains a
  note to store it in a mode-0600 systemd `EnvironmentFile`, plus a pointer to
  `timescale_writer.redact_dsn` for why it will not appear in logs.
- `test_historian_wiring.py` DSN fixtures cannot avoid password-shaped URIs:
  stripping passwords out of them is the entire contract `redact_dsn` exists to
  provide. Marked with allowlist pragmas and shortened so line + pragma stays
  inside the 88-char limit (the fixtures test URI shape, not length).

Verified: detect-secrets reports 0 findings across the new files, ruff and ruff
format clean, 23 wiring tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on needs

`quality-gate` failed at "Type Check (Mypy)" with 16 errors. Root cause is the
typing trap this package already documents: mypy resolves the backend's flat
intra-package imports only when invoked from the backend directory, but the
pre-push hook and CI invoke it from the repo root with
`--follow-imports=skip`, where those imports become `Any`.

#4091 evidently ran mypy from the backend directory and, seeing the
suppressions reported as unused there, deleted them — dropping
`# type: ignore[call-arg]` from 6 `class X(SQLModel, table=True)` declarations
main carries, and adding 6 new table models without it. Under CI's invocation
`SQLModel` is `Any`, so `table=True` becomes
`Unexpected keyword argument "table" for "__init_subclass__" of "object"`.
main has 7 of these suppressions and is green; this branch had 1.

Restored 12 across models.py, audit_log.py, configuration_repository.py,
shift_log.py and saved_investigation.py, matching main's pattern.

Also applied the annotated-local convention #4065 established for this package
(and recorded in SPEC.md) at the four `no-any-return` boundaries #4091 left:
saved_investigation.get, identity_router.get_principal,
advisory_workspace (advisory result construction), and
configuration_workflow (revision transition).

Verified with CI's exact invocation
(`MYPYPATH=src:src/python/src mypy --ignore-missing-imports --follow-imports=skip`
over the changed non-test files, minus CI's legacy filter):
Success, no issues in 46 source files. ruff check and ruff format --check still
clean on all 88 gated files; P1AM backend suite still 1087 passed, 6 skipped.
…> 1708)

`quality-gate` step 24 "Module Size Budget" failed:

    src/p1am_control_system/backend/main.py: 1708 lines [grew beyond baseline (1440)]

`backend/main.py` is already a tracked oversized module in
`config/module_size_budget_baseline.json`. The baseline is a ratchet: known
offenders are frozen at their current size and any growth fails CI. Measured
growth: main 1440 -> 1480 with #4065 (historian wiring, +40) -> 1667 with #4091
(router registration and new endpoints, +187 on its own base) -> 1708 combined.

**This is a deliberate, reviewable loosening of a quality ratchet, and a reviewer
may legitimately reject it and ask for extraction first.** It is done rather than
refactored because splitting a 1708-line FastAPI application module is
substantial independent work with real regression risk, and doing it inside a
consolidation whose job is to preserve two PRs' content unchanged would confuse
provenance. The honest options were "bump the number visibly" or "block the PR";
hiding the growth was not one.

Follow-up worth filing: `main.py` is now the largest non-legacy module in the
baseline and most of the growth is FastAPI router registration and endpoint
bodies that belong in the `*_router.py` modules #4091 already established.
@dieterolson
dieterolson disabled auto-merge August 14, 2026 05:28
@dieterolson
dieterolson enabled auto-merge (squash) August 14, 2026 05:29
codex-scheduled added 4 commits August 14, 2026 00:29
…0-safe

Two real defects in #4091 that CI found and local runs could not.

1. All three test lanes failed on the same two tests (`2 failed, 1712 passed`
   on the required 3.11 lane):

     test_advisory_router::test_representative_advisory_and_disposition_are_review_only
       AttributeError: '_IncludedRouter' object has no attribute 'path'
     test_identity_main_integration::test_main_application_mounts_identity_session_routes
       KeyError: '/api/auth/session'

   Cause: these tests inventory routes by iterating `app.routes` and reading
   `.path`. CI resolves FastAPI unpinned and now installs 0.141.1 / Starlette
   1.6.0, where `include_router()` no longer flattens the included `APIRoute`s
   into `app.routes` — it leaves one `fastapi.routing._IncludedRouter` marker
   with `path=None`, **no `.routes` attribute**, and the real routes hidden on a
   private `original_router` with the prefix on a private `include_context`. The
   included paths are therefore unreachable by walking `app.routes` at all,
   recursively or otherwise. Locally FastAPI is 0.130.0, where the old flattening
   still applies, which is why the suite passed here and failed there.

   Note the app itself is fine: the captured log shows
   `GET /api/operator/advisories/representative 200 OK` and
   `POST .../dispositions 200 OK`. Only the introspection was wrong.

   Note also that #4091's own commit 8671f13 "tolerate pathless router markers"
   is why the second failure is a `KeyError` and not an `AttributeError`: it
   skips entries without a string `path`, which silently produced an EMPTY
   inventory. That is worse than crashing, and dangerously so here —
   test_advisory_router asserts `all("command" not in p and "write" not in p ...)`
   over that inventory, which is the F16 "no authoritative write route"
   guarantee. Over an empty set it passes vacuously, so the safety contract would
   have reported green while verifying nothing.

   Fixed by asking the app for its own schema instead of introspecting the route
   table: new `backend/tests/_route_inventory.py` derives paths and methods from
   `app.openapi()`, which resolves included routers and prefixes itself. Verified
   byte-identical results on fastapi 0.130.0 and 0.141.1, so there is no version
   branch. Both call sites now also assert the inventory is non-empty, so this
   class of check can never go vacuous again, and a new test builds a probe app
   with `include_router` and asserts the nested route is discovered — a direct
   regression guard.

2. The 3.10 lane failed differently and earlier — a collection abort, only 47
   tests collected:

     ImportError: cannot import name 'StrEnum' from 'enum'
     via tests/p1am_control_system/test_backend_security.py -> main ->
         advisory_router -> advisory_workspace:10

   `enum.StrEnum` is new in Python 3.11 and the matrix still runs 3.10. #4091
   added **12** modules importing it unguarded, and because `main` imports them
   transitively, one unguarded import aborts collection for every test module
   that imports the app. Same class as the `datetime.UTC` defect already fixed
   here.

   Fixed with `backend/enum_compat.py`, mirroring the backport already in
   `src/shared/python/compatibility.py` (duplicated rather than imported because
   this package uses flat intra-package imports and runs with the backend
   directory on sys.path, so the shared module is not importable from it). All 12
   imports repointed; `ruff check --fix` re-sorted the affected import blocks.

Verified: the exact module that aborted CI's 3.10 collection now passes (14
tests) on real Python 3.10.20 with CI's fastapi/starlette pins; the two route
tests pass on 3.10+0.141.1, 3.12+0.141.1 and 3.12+0.130.0; full P1AM backend
suite 1087 passed / 6 skipped in the repo venv; ruff check and format clean on
all 102 gated files under the pinned 0.14.10; no bare `from datetime import UTC`.
…real symbol

Follow-up to the previous commit. Repointing 12 modules at `enum_compat` fixed
the Python 3.10 ImportError but broke the required `quality-gate` mypy step with
26 new errors in 5 files, e.g.

  configuration_workflow.py:246: Argument 3 to "_transition" has incompatible
  type "str"; expected "ConfigurationState"  [arg-type]

Cause: CI runs mypy with `--follow-imports=skip`. Stdlib imports still resolve
through bundled typeshed stubs, so `from enum import StrEnum` was fully typed —
but `enum_compat` is first-party source, so it was skipped, `StrEnum` became
`Any`, every `class X(StrEnum)` got an `Any` base, and its members degraded to
bare `str`.

Fixed by importing the stdlib symbol for type checkers and the shim only at
runtime:

    if TYPE_CHECKING:
        from enum import StrEnum
    else:
        from enum_compat import StrEnum

`TYPE_CHECKING` is chosen over `sys.version_info >= (3, 11)` deliberately:
mypy.ini sets no `python_version`, so mypy would infer it from whichever
interpreter the job happens to run, making a version test's outcome
environment-dependent. `TYPE_CHECKING` is unconditionally true for type checkers
and false at runtime, so both sides are deterministic. The single backport
definition stays in `enum_compat`, so this costs no duplication.

Verified:
  * mypy 1.13.0 with CI's exact invocation: Success, no issues in 47 files
    (was 26 errors).
  * Python 3.10.20 + fastapi 0.141.1: 20 passed across the module that aborted
    CI's 3.10 collection plus both route-inventory tests.
  * Semantics preserved where it matters. On 3.10 the backport gives
    `str(Role.OPERATOR) == "operator"` and `json.dumps` emits `"operator"`,
    matching 3.11. On 3.11+ `enum_compat.StrEnum is enum.StrEnum` is True — it is
    literally the stdlib class, so there is no behavioural change at all on the
    versions that have it.
  * Full P1AM backend suite: 1087 passed, 6 skipped.
  * ruff check + format clean on all 90 gated files (pinned 0.14.10); module-size
    budget passes; no bare `from datetime import UTC`; no unguarded StrEnum.
@dieterolson

Copy link
Copy Markdown
Collaborator Author

Note on rust-quality-gate (not a required check): not caused by this PR.

Measured: git diff --name-only origin/main <head> on this branch changes 0 Rust-related files — no
*.rs, no Cargo.toml/Cargo.lock, nothing under rust_core/. So a rust-quality-gate result here
cannot originate from this branch's content; it is either pre-existing or infrastructural (the wheel build
runs on the self-hosted fleet).

Worth stating explicitly rather than implying, because of how this repo's CI is scoped: tests and gates are
selected from changed files, so a defect in a file main has not touched is never exercised on main.
main's test lanes being green is therefore not evidence that a gate passes there — it may simply never
have run. A consolidation is often the first time a given combination is exercised at all, which is why it
surfaces pre-existing problems rather than creating them.

Required checks for this PR remain quality-gate and tests (3.11).

codex-scheduled added 3 commits August 14, 2026 21:33
_route_inventory.py is a 67-line support module exporting only
methods_by_path() and 
oute_paths() — it contains no def test_ and is
imported by the authz-matrix tests. The Changed Test Assertion Check flags it
because it lives under 	ests/, so it needs the same fixture-only allowlist
entry as its sibling _power_supply_helpers.py two lines above.

Allowlisted rather than given a token assertion: adding a meaningless assert to
a helper would satisfy the gate while teaching the next reader that helpers are
tests. The gate's own message offers this path for exactly this case.
Resolves the single SPEC.md conflict as a union: main's #4463 firmware
entries (2026-07-31 harness/CI gating, 2026-08-14 comms watchdog and
bumpless setpoints) kept verbatim, this branch's 2026-08-13 P1AM platform
consolidation entry retained below them. No other file overlapped; main's
firmware tree merges in unmodified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant