Skip to content

feat(plc): factorylm.machine-snapshot.v1 producer over canonical tags (MIRA PRD #3048, PR 2) - #198

Merged
Mikecranesync merged 4 commits into
mainfrom
feat/plc-canonical-snapshot-v1
Aug 2, 2026
Merged

feat(plc): factorylm.machine-snapshot.v1 producer over canonical tags (MIRA PRD #3048, PR 2)#198
Mikecranesync merged 4 commits into
mainfrom
feat/plc-canonical-snapshot-v1

Conversation

@Mikecranesync

Copy link
Copy Markdown
Owner

PR 2 of MIRA PRD #3048 — FactoryLM canonical-source correctness. Companion to #197 (the #161 bug fix) and to MIRA-side #3052 (BRAVO's consumer adapter).

What this contains

1. #188's canonical-tag mapping, rebased onto current main (cherry-pick, commit preserved verbatim with original authorship — its 5 tests pass unchanged). #188 itself can be closed when this merges; the PRD forbade force-pushing that branch.

2. machine_snapshot.py (new) — produces the factorylm.machine-snapshot.v1 envelope from canonical_tags_from_snapshot() output:

  • source_system: "plc_bridge"not factorylm-plc-modbus, which MIRA's ingest rejects (VALID_SOURCE_SYSTEMS); the FactoryLM identity rides in provenance.producer per the PRD's 2026-08-02 amendment
  • deterministic machine_state/active_conditions; comm loss beats fault beats running — a bridge that cannot talk to the PLC does not claim the motor is running
  • quality only ever downgrades: comms lost → measurements uncertain; comm_ok/fault_code stay good (they are the bridge's own directly-known state)
  • tenant_id/snapshot_id/captured_at never defaulted or invented — captured_at comes from the snapshot's own timestamp, no clocks anywhere
  • pure data reshaping: no Modbus/socket/network imports, proven by test; validate_envelope() rejects command/write-shaped fields at any depth
  • not published remotely — envelope + fixtures only, per the PRD ("do not publish it remotely yet"; relay publishing is PR 3, unclaimed)

3. Shared contract fixtures vendored verbatim from MIRA #3052 into contracts/machine_snapshot/ — both repos now test the exact same payloads. The golden fixture passes validate_envelope untouched; each of the four invalid fixtures fails for its documented reason; the producer's output is asserted shape-identical to the golden payload (same key sets, same vocabularies).

PRD PR-2 acceptance, mapped

requirement where proven
normal snapshot produces all required canonical tags test_healthy_envelope_is_valid_and_complete (all 7 conv_simple.*)
faulted/comms-lost preserves fault_code + comm_ok=false test_comms_lost_preserves_fault_code_and_comm_ok_false
names/values/timestamps/quality deterministic test_deterministic + no-clock construction
FactoryLM fixture matches the MIRA consumer contract TestSharedFixtureCompatibility (vendored fixtures, shape assert)
no write-capable Modbus method in the snapshot path test_module_is_pure_data_reshaping + observation-only validator

180 unit tests pass (151 pre-existing + 5 from #188 + 24 new), run on the PLC laptop.

Slice coordination

Claimed on MIRA #3048: PLC laptop (LAPTOP-0KA3C70H), session f985f0fe. Depends on nothing; #197 (the #161 fix) is independent and both can merge in either order. If the fixtures change in MIRA #3052 review, re-sync contracts/machine_snapshot/ here and re-run tests/unit/test_machine_snapshot.py.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Dgej4vU7mMnYTmU6QXsH3n

CharlieNode and others added 2 commits August 2, 2026 06:15
…tags (PRD #3048 PR 2)

Builds the versioned, read-only machine-snapshot envelope from the
canonical conv_simple tags that canonical_tags_from_snapshot() (#188,
included here rebased onto main) projects out of a Micro820 TagSnapshot.

- source_system is plc_bridge (MIRA ingest rejects anything else);
  the FactoryLM identity rides in provenance.producer per the PRD's
  2026-08-02 amendment.
- machine_state/active_conditions derived deterministically; comm loss
  beats fault beats running — a bridge that cannot talk to the PLC does
  not claim the motor is running.
- Quality only ever downgrades: comms lost -> measurements uncertain,
  while comm_ok/fault_code stay good (they are the bridge's own state).
- tenant_id/snapshot_id/captured_at are never defaulted or invented;
  captured_at comes from the snapshot's own timestamp.
- Pure data reshaping: no Modbus, sockets, or network imports — proven
  by test. Observation-only enforced by validate_envelope, which rejects
  command/write-shaped fields at any depth.

Vendors the SHARED contract fixtures from MIRA PR #3052 verbatim into
contracts/machine_snapshot/ — both repos test the exact same payloads.
validate_envelope passes the golden fixture untouched, fails each of
the four invalid fixtures for its documented reason, and the producer's
output is asserted shape-identical to the golden payload.

180 unit tests pass: 151 pre-existing + 5 from #188 + 24 new.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dgej4vU7mMnYTmU6QXsH3n
- #199: shared fixtures carry all seven conv_simple.* canonical tags
  (adds conv_simple.sort_divert_active); golden-shape test now compares
  tag path sets, not just tag-entry key shape
- #200: comms-loss state renamed comm_lost -> comm_down to match MIRA's
  current-state vocabulary
- #201: validate_envelope now enforces source_system=plc_bridge and
  canonical tag paths; unknown quality is accepted (consumer downgrades
  to uncertain, never good) instead of rejected
- #202: factorylm_plc/__init__ hardware exports are lazy (PEP 562) so
  importing machine_snapshot pulls no pymodbus; proven by a
  fresh-interpreter import test
- #203: active E-stop returns "estopped" (comm_down still dominant);
  an E-stopped machine can never surface as merely "running"

188 unit tests pass (baseline was 180).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dgej4vU7mMnYTmU6QXsH3n
@Mikecranesync

Copy link
Copy Markdown
Owner Author

Repair pass complete for the adversarial review (commit 2926e38):

Note: landed as one atomic commit rather than the suggested two — the #200/#203 changes interleave in the same function/docstring, and the #202 import-purity test would fail at any intermediate commit without the lazy __init__ (bisect safety).

Tests (local, since only brain-ingest runs on PRs until #205):

PYTHONPATH=src pytest tests/unit/test_machine_snapshot.py tests/unit/test_modbus_tag_source.py -q
37 passed in 0.27s

PYTHONPATH=src pytest tests/unit -q
188 passed in 0.48s   (baseline before repair: 180 passed)

No remote publishing, no relay integration, no plant writes.

Heads-up for the MIRA side: MIRA main's contracts/machine_snapshot/snapshot_v1_valid.json (merged via #3052) still has only six tags — fixture sync PR to MIRA is being prepared so the vendored-verbatim invariant holds.

…r read

P1 from the post-repair review: height_sensor_mm and sort_divert_active
default to 0/False in canonical_tags_from_snapshot when the snapshot's
io dict lacks them — and ModbusTagSource.tick() never populates those
keys, because the bench Micro820 map has no height-sensor or sort-divert
I/O. The envelope then claimed quality=good for values that were
deterministic defaults, not observations.

The seven-tag contract shape is kept; unsourced io-backed tags now emit
quality=uncertain (downgrade-only rule — a value the bridge never read
is never good). Wiring fake signals into the reader was rejected: that
would invent plant data the hardware map does not carry.

- modbus_tag_source: IO_SOURCED_CANONICAL_TAGS + unsourced_canonical_tags()
- machine_snapshot: _tag_quality downgrades unsourced tags first
- tests: unsourced-vs-read quality cases + fake-pymodbus-client tick()
  end-to-end proof that the REAL reader path emits uncertain for both

191 unit tests pass (was 188).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dgej4vU7mMnYTmU6QXsH3n
@Mikecranesync

Copy link
Copy Markdown
Owner Author

P1 from the post-repair review fixed (commit 19b44e2):

Finding: height_sensor_mm / sort_divert_active default to 0/False in canonical_tags_from_snapshot when the snapshot's io dict lacks them — and ModbusTagSource.tick() never populates those keys (the bench Micro820 map has no height-sensor or sort-divert I/O). The envelope claimed quality="good" for values that were deterministic defaults, not observations.

Fix taken: kept the seven-tag contract shape, but unsourced io-backed tags now emit quality="uncertain" (the downgrade-only rule: a value the bridge never read is never good). Wiring the tags to "real PLC-backed signals" was deliberately not done — the hardware map carries no such I/O, so any mapping would invent plant data.

  • modbus_tag_source.IO_SOURCED_CANONICAL_TAGS + unsourced_canonical_tags(snapshot) — the single place that knows which canonical tags ride on optional io keys.
  • machine_snapshot._tag_quality checks unsourced first, before the comm-ok branch.
  • New tests: unsourced-vs-actually-read quality cases, plus TestRealReaderPath — a fake-pymodbus-client tick() end-to-end proof that an envelope built from what the real reader path actually produces marks both tags uncertain and the genuinely-read five good.
PYTHONPATH=src pytest tests/unit -q
191 passed   (was 188)

P2 sequencing executed: #206 merged first, so this push runs the real PLC Modbus Tests workflow on this PR; #197 branch updated from main to refresh its checks too.

@Mikecranesync

Copy link
Copy Markdown
Owner Author

Reviewed the full diff. The producer is good work — it honors every PRD amendment (plc_bridge not factorylm-plc-modbus, FactoryLM identity in provenance.producer, downgrade-only quality, captured_at from the source with an explicit raise instead of now(), deterministic envelope with no clock or randomness). The unit job now runs for real: 191 collected, 191 passed (it ran nowhere before #206 landed).

Two things before merge.


1. active_conditions asserts e_stop_active from a snapshot this module has just declared unreliable

if bool(snapshot.e_stop):
    conditions.append("e_stop_active")

if code == COMM_LOSS_ERROR_CODE:
    conditions.append(_slug(ERROR_CODES[COMM_LOSS_ERROR_CODE]))
    return "comm_down", conditions

_tag_quality in this same file already reasons the point correctly: with comms lost the values are "whatever the bridge last held — uncertain, never good", excepting only comm_ok and fault_code because those are the bridge's own state rather than something read across the dead link. e_stop gets no such exception — it comes across the same dead link — yet the condition list asserts it unconditionally.

So a comm_down envelope can ship active_conditions: ["e_stop_active", "communication_lost"], asserting an E-stop state it cannot currently observe.

I initially wrote this off as "over-reporting E-stop is the fail-safe direction." On re-examination I think that's backwards. Claiming the E-stop is engaged when it may have been released since comms dropped is the direction that could lead a technician to believe a machine is safed when it isn't. Omitting it costs a little information; asserting it fabricates plant state. MIRA's grounding doctrine is explicit that we never assert plant context without evidence — and during comm loss there is no current evidence either way.

Suggested: move the e_stop_active append below the comm-loss return, or keep it but tag it as last-known rather than active (e.g. e_stop_active_last_known) so the consumer can render the uncertainty. Either way, please make the intent explicit in the docstring — the current asymmetry with _tag_quality reads as an oversight.

2. _walk's forbidden-token scan is a substring match on keys, and this is about to matter

FORBIDDEN_FIELD_TOKENS = ("command", "write", "actuate", "setpoint_write", "control")
...
if any(tok in lowered for tok in FORBIDDEN_FIELD_TOKENS):

Substring, over every key at every depth. controller_model, controller_serial, control_panel_id — ordinary PLC provenance fields — would all be rejected as "forbidden command/write field."

This was theoretical until today. MIRA PR #3059 (PR 3, the transport) now writes snapshot-scoped fields into per-tag metadata.factorylm_snapshot, so the key surface this walks is actively growing.

Suggested: match whole _-separated segments, or use an exact-name denylist. Keep the check — enforcing an observation-only payload is exactly right, it just needs to not false-positive on controller_*.


Nothing else blocking. The fixtures are byte-identical to MIRA's copies (I verified sha256 across all six files), which is the discipline this boundary needs — though it's currently doctrine with no mechanism, so I've proposed a checksum guard on both sides separately.

@Mikecranesync

Copy link
Copy Markdown
Owner Author

Follow-up from MIRA #3060 (just opened): I added contracts/machine_snapshot/CHECKSUMS.sha256 plus a test that fails loudly if any fixture changes without the manifest being regenerated, and that asserts every fixture on disk is listed.

This PR should vendor that file and add the equivalent assertion, or the guard only protects one side — and a one-sided edit is precisely the failure mode, since each repo would keep passing against its own copy.

The six files are byte-identical between the two repos as of this review (I verified sha256 across all of them), so vendoring CHECKSUMS.sha256 verbatim will pass here with no fixture changes. Regeneration command is in the test's docstring.

@Mikecranesync
Mikecranesync merged commit 4521224 into main Aug 2, 2026
2 checks passed
@Mikecranesync
Mikecranesync deleted the feat/plc-canonical-snapshot-v1 branch August 2, 2026 12:42
Mikecranesync added a commit that referenced this pull request Aug 2, 2026
… E-stop across a dead link (#207) (#209)

Three review items from MIRA PRD #3048 that merged with #198 unaddressed. All
three feed MIRA's technician-visible live evidence.

1. Communication health was BEING error code 5, in two places independently
   (machine_state_from_snapshot, and canonical_tags_from_snapshot deriving
   conv_simple.comm_ok from `int(error_code) != 5`). Renumbering ERROR_CODES
   would silently change what machine_state means. TagSnapshot now carries an
   optional `comm_ok` link indicator, and comm_ok_from() is THE one definition
   both call sites use -- authoritative when the source supplies it, falling
   back to the documented error-code proxy only when it does not (which is all
   the Micro820 bridge exposes today).

2. active_conditions asserted e_stop_active unconditionally, then returned
   comm_down -- claiming a CURRENT E-stop read across the same dead link that
   _tag_quality already declares unreliable. That is the dangerous direction:
   an E-stop reported as engaged when it may have been released since comms
   dropped can lead a technician to believe a machine is safed when it isn't.
   It is now reported as e_stop_active_last_known so the consumer can render
   the uncertainty. A pre-existing test asserted the old behaviour while its
   own docstring said the opposite ("the bridge cannot vouch for E-stop
   either") -- updated to match its stated reasoning.

3. The forbidden-field scan was a SUBSTRING denylist, so controller_model,
   controller_serial and control_panel_id were all rejected as command fields.
   Live rather than theoretical since MIRA #3059 began writing snapshot fields
   into per-tag metadata. Now matched on whole _-separated segments, with bare
   `control` dropped (ambiguous -- control_panel_id is provenance) and the
   actual control-word shapes named explicitly. A real command/write/actuate/
   setpoint_write field is still rejected at any depth.

195 passed on main -> 204 passed here (+9), zero regressions.

Co-authored-by: CharlieNode <charlienode@CharlieNodes-Mac-mini.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant