Skip to content

feat(equipment): PIDINST read closure (slice E.1)#34

Merged
xmap merged 1 commit into
mainfrom
asset-persistent-id-slice-e
Jun 4, 2026
Merged

feat(equipment): PIDINST read closure (slice E.1)#34
xmap merged 1 commit into
mainfrom
asset-persistent-id-slice-e

Conversation

@xmap
Copy link
Copy Markdown
Owner

@xmap xmap commented Jun 4, 2026

Summary

Closes slice D Lock 14: Asset PIDINST records are now reachable over HTTP + MCP, and Slice C's OwnerStateNotAvailableError stops firing for owner-populated Assets through the production read route. The slice C serializer (PR #30) is no longer dead code on main.

What ships

  • Asset state: commissioned_at + decommissioned_at folded from existing AssetRegistered.occurred_at (genesis Commissioned lifecycle) and AssetDecommissioned.occurred_at. No new events.
  • Atlas migration 20260603130000_add_asset_summary_persistent_id.sql: three new columns on proj_equipment_asset_summary (lifecycle timestamps + reserved persistent_id JSONB for slice F).
  • Feature slice features/get_asset_pidinst/ with query.py + _view_assembler.py (bare load_asset + load_model + load_family, matching get_asset_integration_view precedent) + handler.py + route.py (with PidinstRecordResponse Pydantic mirror) + tool.py (MCP per BC slice-contract convention).
  • Route GET /assets/{asset_id}/pidinst returns 200 / 404 / 409 (Owner/Manufacturer state) / 422 (LandingPage/AssetName view-prep) / 500 (defensive PidinstRecordInvariantError). Two shared exception handlers wire the four routable PidinstSerializationError subclasses via the existing _handle_cannot_transition-style registration loop in equipment/routes.py.
  • Bootstrap check in _bootstrap.py: missing landing-page-template raises RuntimeError at startup.
  • Settings on cora.infrastructure.config.Settings: facility_publisher + landing_page_template.

Tests (all green)

  • Handler-level integration suite at tests/integration/test_get_asset_pidinst_handler_postgres.py (12 scenarios per memo §8: 6 happy + 4 negative + 2 closure-proof).
  • HTTP-level contract suite at tests/contract/test_get_asset_pidinst_endpoint.py (4 orthogonal route-layer assertions; a regression dropping the exception-handler tuple would silently 500 here).
  • Architecture fitness: status_map_is_complete (L15, AST-walks add_exception_handler Call nodes), assembler_uses_loaders (L17, imports + no-SQL), closure_proof_suite_is_present (R4, both suites must exist with required test counts).
  • Unit: 8 assembler + 4 handler + 4 projection + 2 bootstrap.

Notes

  • The slice's earlier memo posture of "no MCP exposure in E.1" was wrong: every sibling read slice ships tool.py + tools-registration per the BC slice-contract fitness. tool.py mirrors get_asset_integration_view/tool.py shape.
  • Response body uses the BC-uniform {"detail": str(exc)} shape (structured {"code": ..., "asset_id": ...} deferred to a future BC-wide slice per memo Defer D-ERRBODY).
  • After applying in non-greenfield deployments the projection MUST be rebuilt from the event store so pre-migration Assets pick up commissioned_at.

Design memo

project_asset_persistent_id_design.md (23 Locks, 17 Defers including D-ETAG, D-ASSIGN, D-ERRBODY). Three rounds of Stage 3 gate review applied (memo round 1, memo round 2 with regression fixes, implementation round with this commit's corrections).

Follow-ups

  • Slice F (deferred per D-ASSIGN): Asset.persistent_id field + assign_persistent_id mutation + DataCite mint adapter + DoiMinter port. Gated on facility credentials.
  • Slice E.Fixture (parallel): Fixture-tier PIDINST design memo per the session-2026-06-04 discussion about PID-the-Fixture as HZB-style experimental-station identity.
  • project_fixture_configuration_hash_followup.md captures the Fixture content-hash idea (realization-hash vs configuration-hash) for Federation BC cross-facility configuration sharing; not designed, not scheduled.

Test plan

  • uv run ruff check + format --check clean
  • uv run pyright src/cora/equipment/ 0 errors
  • uv run tach check clean
  • All slice E.1 unit + arch + contract tests pass
  • Pre-commit hooks pass
  • CI green on push

Closes slice D Lock 14: Asset PIDINST records are now reachable
over HTTP + MCP, and Slice C's OwnerStateNotAvailableError stops
firing for owner-populated Assets through the production read
route.

Asset state extension: commissioned_at + decommissioned_at fields
folded from existing AssetRegistered.occurred_at (genesis
Commissioned lifecycle) and AssetDecommissioned.occurred_at. No
new events. Atlas migration 20260603130000 adds three columns to
proj_equipment_asset_summary (lifecycle timestamps + reserved
persistent_id JSONB for slice F).

Feature slice features/get_asset_pidinst/ with query.py (sibling
6/6 read-slice convention), _view_assembler.py calling bare
load_asset + load_model + load_family functions (matches
get_asset_integration_view/handler.py precedent), handler.py,
route.py with PidinstRecordResponse Pydantic mirror, and tool.py
exposing MCP per BC slice-contract convention. Route
GET /assets/{asset_id}/pidinst returns 200 / 404 / 409 (state) /
422 (view prep) / 500 (defensive). Two shared exception handlers
wire the four routable PidinstSerializationError subclasses.
Response body uses BC-uniform {"detail": str(exc)} shape
(structured error body deferred to D-ERRBODY).

Tests (all green):

- Handler-level integration suite at
  tests/integration/test_get_asset_pidinst_handler_postgres.py
  (12 scenarios per memo section 8).
- HTTP-level contract suite at
  tests/contract/test_get_asset_pidinst_endpoint.py (4 orthogonal
  route-layer assertions; a regression dropping the
  exception-handler tuple would silently 500 here).
- Architecture fitness: status_map_is_complete (L15, AST-walks
  add_exception_handler Call nodes), assembler_uses_loaders (L17),
  closure_proof_suite_is_present (R4).
- 8 assembler + 4 handler + 4 projection + 2 bootstrap unit tests.

The slice's earlier memo posture of "no MCP exposure in E.1" was
wrong: every sibling read slice ships tool.py + tools-registration
per the BC slice-contract fitness. tool.py mirrors
get_asset_integration_view/tool.py shape.

Memo: project_asset_persistent_id_design.md (23 Locks, 17 Defers).
Three rounds of Stage 3 gate review applied; the round-3 review on
the implementation surfaced the no-MCP and structured-error-body
misalignments which this commit corrects.

Slice F follows for the write path (Asset.persistent_id + assign
mutation + DataCite mint adapter; gated on facility credentials).
Slice E.Fixture follows in parallel for the Fixture-tier PIDINST
sibling per the session-2026-06-04 discussion. Also captured:
project_fixture_configuration_hash_followup.md notes the Fixture
content-hash idea for Federation BC cross-facility configuration
sharing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@xmap xmap force-pushed the asset-persistent-id-slice-e branch from 4df9af6 to 73e7c95 Compare June 4, 2026 14:33
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 4, 2026

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/equipment
  _bootstrap.py
  routes.py 297-298
  tools.py
  wire.py
  apps/api/src/cora/equipment/aggregates/asset
  evolver.py
  state.py
  apps/api/src/cora/equipment/features/get_asset_pidinst
  __init__.py
  _view_assembler.py
  handler.py 77-85
  query.py
  route.py 144, 273
  tool.py 92-99
  apps/api/src/cora/equipment/projections
  asset.py
  apps/api/src/cora/infrastructure
  config.py
Project Total  

This report was generated by python-coverage-comment-action

@xmap xmap merged commit b31d89f into main Jun 4, 2026
4 checks passed
xmap added a commit that referenced this pull request Jun 5, 2026
…w-ups (#39)

* fix(equipment): Asset evolver carries commissioned_at + decommissioned_at through AssetOwnerRemoved + AssetAttachedToFixture

The Asset evolver's Critical Invariant docstring requires every
transition arm to preserve commissioned_at and decommissioned_at from
prior state. Two arms violated the invariant by omitting both fields
from their Asset(...) construction. Replaying a stream that included
either event silently wiped both timestamps to None, corrupting PIDINST
Property 11 lifecycle dates on any Asset whose owners changed or that
was attached to a Fixture after commissioning.

Same silent-data-loss shape as the Sub-Stage B.6 bug previously caught
on the fixture_id field. That earlier fix added fixture_id to the
preserve-fields list but did not catch the parallel commissioned_at /
decommissioned_at omissions on these two arms, which were folded in
later via the PIDINST read closure work.

Surfaced by the PseudoAxis Stage-1 design's implementation-readiness
gate review (round 2) as an out-of-scope finding while staging the
partition_rule threading work.

Two targeted regression tests pin the carry-forward:
- test_evolve_asset_owner_removed_preserves_lifecycle_timestamps
- test_evolve_asset_attached_to_fixture_preserves_lifecycle_timestamps

Both assert that prior commissioned_at / decommissioned_at survive the
respective evolver transitions. Mirror the existing
test_evolve_<transition>_preserves_<field> pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(equipment): widen Asset evolver lifecycle-date carry-forward to matrix + AST fitness

Follow-up to 493aad0bb (`fix(equipment): Asset evolver carries
commissioned_at + decommissioned_at through AssetOwnerRemoved +
AssetAttachedToFixture`). That surgical fix pinned the two
originally-broken arms with arm-specific regression tests. This
commit adds two complementary layers of coverage so the bug class
cannot re-emerge on a future evolver arm.

Layer 1: full carry-forward matrix in
test_asset_lifecycle_dates_evolver.py. Mirrors the existing per-field
parametrize convention (test_evolve_mutation_preserves_drawing,
..._model_id, ..._alternate_identifiers) and extends it to both
PIDINST lifecycle dates across all 17 non-writer arms, explicitly
including the owner_added / owner_removed / fixture_attached /
fixture_detached arms whose absence from the older parametrize lists
is what allowed the original bug to ship undetected. Writer-arm
behavior (AssetRegistered as genesis writer; AssetDecommissioned as
terminal writer) is pinned in two dedicated tests.

Layer 2: AST-based architecture fitness in
test_asset_evolver_lifecycle_dates_carry_forward.py. Parses
evolver.py, walks every `case <EventName>(...):` arm in `evolve`,
and asserts the inner `Asset(...)` call contains both
`commissioned_at=prior.commissioned_at` and
`decommissioned_at=prior.decommissioned_at` unless the arm is one
of the documented writers. The structural check catches arms that
no behavior test was written for; the behavior matrix catches
regressions in arms that exist today. Sanity-checked by reverting
the original evolver patch and confirming both layers report the
violations.

Surfaced by the Equipment-BC balance audit (post-ship review of
slice E.1 PIDINST work) as the highest-priority recommendation:
protects PR #34 from corrupting on any Asset that has owners
removed or is attached to a fixture.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(equipment): move _assembly_content_hash.py into the assembly aggregate dir

Files at the aggregates/ level (one directory above the per-aggregate
subdirs) signal "shared by multiple aggregates." _drawing.py and
_placement.py earn that placement with consumers across Mount, Frame,
and several siblings. _assembly_content_hash.py did not: its only
consumers are define_assembly/decider.py, version_assembly/decider.py,
and its own test. Living at the shared-helper level was misleading
and would have nudged future designers (notably the Fixture-tier
content-hash follow-up tracked in project_fixture_configuration_hash_followup)
toward extending this module rather than building a sibling inside
aggregates/fixture/.

Move:
  apps/api/src/cora/equipment/aggregates/_assembly_content_hash.py
    -> apps/api/src/cora/equipment/aggregates/assembly/_content_hash.py

Touches the two import sites (define_assembly + version_assembly
deciders), the test file's import, and the hardcoded path entry in
test_no_assembly_asset_level_literal.py. The PAYLOAD_TYPE constants
and the function names are unchanged, so no event-shape or hash drift.
The source docstring is rewritten to explain the single-aggregate
scope and to flag the future Fixture-tier sibling expectation
explicitly.

Surfaced by the Equipment-BC balance audit as the lowest-effort
honest-architecture fix: zero fold-cost, zero cross-BC ripple, and
removes a load-bearing-looking signal that wasn't load-bearing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(equipment): factor Assembly per-id family lookup into find_missing_families_per_id

define_assembly and version_assembly each opened the family stream
fan-out inline: build the referenced FamilyId set, await
asyncio.gather over load_family for each id, zip the results, and
collect the missing ids into a frozenset before threading into
context. The two implementations were near-identical (10-ish lines
each, two formatting variants on the same logic). Extracting the
lookup into a named helper in family/read.py shrinks each handler
by ~6 lines, removes the asyncio import + asyncio.gather doctring
mention from both, and earns the rule of three the moment a third
per-id caller appears.

Scope deliberately narrower than the Equipment-BC balance audit
recommendation:

  - Model handlers (define_model, add_model_family) keep their
    inline `list_all_family_ids` + set-difference. They are
    monkeypatched at the handler-module level (`handler.list_all_family_ids`)
    in 15 test files; routing the lookup through a helper would
    invalidate every one of those patches and force a 15-file stub-
    shape rewrite for a 2-line per-handler win. Revisit when those
    tests get migrated to patch at the read-module location.

  - Asset handlers (register_asset, add_asset_family) keep their
    inline `load_model` calls. register_asset uses the loaded model
    only for an existence check with elaborate INFO logging on the
    not-found path; add_asset_family loads the model AND reads
    `model.declared_family_ids` for a subset gate. The two share a
    surface shape (`if model is None: raise ModelNotFoundError(id)`)
    but their downstream use differs enough that a single helper
    would either hide the asymmetry or carry an awkward parameter
    surface.

The unused sibling helper `find_missing_families_in_registry`
(bulk-SQL strategy for Model-side callers) was authored, then
deleted before commit per the project's defer-until-trigger
discipline: speculative helpers without a current caller are noise.
When the Model-side test surface rework lands, that helper can ship
alongside it in one commit.

Surfaced by the Equipment-BC balance audit (Rec #3). The audit's
original prescription targeted 6 handlers and proposed two
strategy-variant helpers in family/read.py + matching helpers in
model/read.py. Reading the actual code revealed the Model side has
a test-shape coupling the audit did not see and the Asset side
isn't a duplication at all.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(equipment): log PidinstRecordInvariantError at the application handler before re-raising

PidinstRecordInvariantError is intentionally NOT wired to a custom
HTTP handler per Lock 11 of project_asset_persistent_id_design:
it is a server-bug backstop, not a routable client-facing error,
and FastAPI's default 500 is the locked outcome (enforced by
tests/architecture/test_get_asset_pidinst_status_map_is_complete.py
which both pins the 4 routable PIDINST errors AND asserts this one
is NOT registered).

The locked policy left a real observability gap: FastAPI's default
500 returns the plain-text body `Internal Server Error` (not the
BC-uniform `{"detail": str(exc)}` shape every other Equipment
error uses) and prints the stack trace to stderr via traceback,
NOT through the project's structlog `_log` channel. Sentry /
Loki / structured monitoring sees nothing actionable: no
asset_id, no correlation_id, no principal_id, no JSON fields.

The naive fix (wrap construction inside `to_pidinst_record`) was
rejected: that module is governed by L22 / L28 purity
(tests/architecture/test_pidinst_serializer_purity.py) which
forbids I/O accumulation including logging. The serializer must
stay a pure transform.

The right placement is the application boundary: the
`get_asset_pidinst` handler wraps the `to_pidinst_record(view)`
call in a try/except for `PidinstRecordInvariantError`, emits a
structured `_log.exception("get_asset_pidinst.pidinst_record_invariant", ...)`
with asset_id + principal_id + correlation_id + invariant reason,
then re-raises. FastAPI's default 500 still fires, the body shape
is unchanged, the locked policy is preserved, and operators get
the structured trail they need to diagnose a recurrence.

The handler-level placement also matches the existing convention
for error-path logging in non-pure modules
(infrastructure/idempotency_pruner.py:97,
infrastructure/auth/bearer_auth_middleware.py:277,
infrastructure/projection/worker.py:209).

Three docstrings updated to point at the new arrangement:
- _pidinst_serializer.to_pidinst_record notes that observability
  lives in the application handler and explains why (L22).
- get_asset_pidinst/handler.py updates the 500-line in the
  error-routing table to cite L11 explicitly and to point at the
  in-handler log.
- get_asset_pidinst/route.py mirrors the handler docstring's
  500-line for the route-layer reader.

New unit test test_handler_logs_and_reraises_on_pidinst_record_invariant_error
monkeypatches to_pidinst_record to raise the invariant error, runs
the handler inside structlog.testing.capture_logs(), and asserts
both the re-raise AND the structured-log emission (event name,
asset_id, principal_id, correlation_id, reason, log_level=error).

Surfaced by the Equipment-BC balance audit (Rec #5). The audit's
original prescription was to register a custom HTTP handler
mapping PidinstRecordInvariantError to a structured 500; that
recommendation would have failed the existing fitness test the
audit did not see. The deeper investigation isolated the genuine
observability gap and a placement that respects every locked
constraint (Lock 11, L22, both fitness tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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