Skip to content

feat(equipment): Asset.persistent_id write path + DoiMinter port + Stub adapter (slice F.1)#38

Merged
xmap merged 2 commits into
mainfrom
asset-persistent-id-slice-f
Jun 5, 2026
Merged

feat(equipment): Asset.persistent_id write path + DoiMinter port + Stub adapter (slice F.1)#38
xmap merged 2 commits into
mainfrom
asset-persistent-id-slice-f

Conversation

@xmap
Copy link
Copy Markdown
Owner

@xmap xmap commented Jun 5, 2026

Summary

Closes the D-ASSIGN deferral from slice E.1 (PR #34). Adds the WRITE path for PIDINST v1.0 Property 1 at the Asset tier:

  • Asset.persistent_id: PersistentIdentifier | None aggregate field (additive; default None; placed as LAST field per the verified field-ordering check)
  • PersistentIdentifier frozen-dataclass VO with trim + 1-200 length invariant
  • Closed PersistentIdentifierScheme StrEnum {DOI, HANDLE}, values matching PidinstIdentifierType byte-for-byte (no translation map at the serializer)
  • AssetPersistentIdAssigned event (alphabetical position in __all__; grouped by domain-arm in the discriminated union per BC convention)
  • assign_asset_persistent_id mutation slice (command + decider + handler + route + MCP tool)
  • DoiMinter Protocol port at cora.equipment.ports.doi_minter
  • StubDoiMinter adapter at cora.equipment.adapters.stub_doi_minter (ships in F.1, no DataCite credentials needed)
  • Match/case serializer extension on _build_identifier with NO default arm (adding a third scheme member fires pyright as an unhandled-case error)
  • Tach rows for cora.equipment.ports + cora.equipment.adapters

The production DataCite adapter (PUT /dois/{id} + HTTP Basic + 429 backoff + cassette tests) is deferred to F.2 behind facility-credential gating.

Why

Operators need a server-mint flow today so that downstream PIDINST serialization swaps from urn:uuid: to the assigned DOI as soon as the assign slice runs. Slice E.1 reserved the JSONB column and shipped the read closure; this slice closes the write side end-to-end on a Stub so the whole flow can be proven on the inert mint, then F.2 swaps the Stub for the production adapter once credentials land at the pilot facility.

Key locks

  • Set-once domain invariant per F3.3 (DataCite Findable immutability): once Asset.persistent_id is set, no further assign / clear / reassign is accepted. Slice G owns withdraw via a separate timestamp facet.
  • Server-mint posture per the non-determinism-injection principle: the route accepts (scheme, suffix | None) and the handler resolves the PersistentIdentifier from the DoiMinter port before invoking the pure decider, keeping the decider deterministic and trivially testable. ONE minter call site (the handler), not two (route + MCP tool).
  • BC-tier wiring per the operation BC ControlPort precedent: wire_equipment(deps) stashes the StubDoiMinter on the equipment-deps namespace, NOT through the cross-BC Kernel dataclass.
  • Error surface: 5 classes mapped uniformly. 400 InvalidPersistentIdentifierValueError, 404 AssetNotFoundError, 409 AssetPersistentIdAlreadyAssignedError + AssetPersistentIdAssignmentForbiddenError, 502 PersistentIdentifierMintError. PersistentIdentifierMintError lives at cora.equipment.ports.doi_minter next to the Protocol it documents, NOT in aggregates/asset/state.py, because it is an adapter-tier failure not a decider-raised genesis error.

Naming note

The slice directory was renamed from assign_persistent_id to assign_asset_persistent_id at implementation time per the test_slice_dir_carries_subject architecture fitness (sibling pattern: add_asset_owner, add_asset_alternate_identifier, add_asset_family, add_asset_port). The command class follows: AssignAssetPersistentId. The Pydantic wire models follow: AssignAssetPersistentIdRequest / AssignAssetPersistentIdResponse. The URL stays terse (/assets/{asset_id}/assign-persistent-identifier) because the URL path already carries the SUBJECT via /assets/.

Scale

40 files, 3943 insertions / 6 deletions. 118 slice tests + 16657 architecture-tier tests passing post-rebase.

Test plan

  • Unit tests: VO + PBT + scheme + decider + decider-PBT + evolver + events + summary-projection + serializer + wire (10 files)
  • Integration tests: route (full HTTP matrix incl. 502 mint-error path) + tool (MCP) + pidinst-view round-trip (3 files)
  • Contract test: OpenAPI shape + 7-status documentation
  • Architecture fitness: paired-PBT for new decider, decider-purity (no minter import), no-clear-no-reassign command, scheme byte-match, slice-subject, all pre-existing fitness green
  • Pre-commit: ruff + ruff-format + pyright + tach + secrets + architecture-fitness all green

Follow-ups

  • F.2 (production DataCite adapter; gated on facility credentials)
  • Slice E.Fixture (parallel Fixture-tier PIDINST design memo; tracked at project_fixture_configuration_hash_followup)

Closes the D-ASSIGN deferral from slice E.1.

Generated with Claude Code

…ub adapter (slice F.1)

Closes the D-ASSIGN deferral from slice E.1. Adds the WRITE path for PIDINST
v1.0 Property 1 at the Asset tier: an aggregate-tier persistent_id field, a
PersistentIdentifier value object, the closed PersistentIdentifierScheme enum
{DOI, HANDLE}, the assign_asset_persistent_id mutation slice, the
AssetPersistentIdAssigned event, the DoiMinter Protocol port, and a
StubDoiMinter adapter that ships without DataCite credentials. The production
DataCite adapter (PUT /dois/{id}, HTTP Basic, 429 backoff, cassettes) is
deferred to F.2 behind facility-credential gating.

WHY: operators need a server-mint flow today so that downstream PIDINST
serialization swaps from urn:uuid: to the assigned DOI as soon as the assign
slice runs. Slice E.1 reserved the JSONB column and shipped the read closure;
slice F closes the write side end-to-end on a Stub so the whole flow can be
proven on the inert mint, then F.2 swaps the Stub for the production adapter
once credentials land at the pilot facility.

Set-once domain invariant enforced at the decider per F3.3 (DataCite Findable
immutability): once Asset.persistent_id is set, no further assign / clear /
reassign is accepted (slice G owns withdraw via a separate timestamp facet).
Server-mint posture per [[project-non-determinism-principle]]: the route
accepts (scheme, suffix | None) and the handler resolves the PersistentIdentifier
from the DoiMinter port before invoking the pure decider, keeping the decider
deterministic and trivially testable. BC-tier wiring per the operation BC
ControlPort precedent: wire_equipment(deps) stashes the StubDoiMinter on the
equipment-deps namespace, NOT through the cross-BC Kernel dataclass.

Error surface: 5 classes mapped uniformly. 400 InvalidPersistentIdentifierValueError,
404 AssetNotFoundError, 409 AssetPersistentIdAlreadyAssignedError +
AssetPersistentIdAssignmentForbiddenError, 502 PersistentIdentifierMintError
(error class lives at cora.equipment.ports.doi_minter next to the Protocol it
documents, NOT in aggregates/asset/state.py, because it is an adapter-tier
failure not a decider-raised genesis error).

Naming: slice directory renamed from assign_persistent_id to
assign_asset_persistent_id at implementation time per the
test_slice_dir_carries_subject architecture fitness (sibling pattern:
add_asset_owner, add_asset_alternate_identifier, add_asset_family,
add_asset_port). Command class AssignAssetPersistentId, Pydantic models
AssignAssetPersistentIdRequest / AssignAssetPersistentIdResponse follow. URL
stays terse (/assets/{asset_id}/assign-persistent-identifier) because the URL
path already carries the SUBJECT via /assets/.

Serializer: _build_identifier now uses match/case on PersistentIdentifierScheme
with NO default arm, so adding a third scheme member fires pyright as an
unhandled-case error at type-check time. URN fallback for None stays outside
the match.

Tach: adds cora.equipment.ports and cora.equipment.adapters rows.

Scale: 40 files, 3943 insertions / 6 deletions. 118 slice tests + 16571
architecture fitness tests green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 5, 2026

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/equipment
  __init__.py
  _asset_persistent_identifier_body.py
  _pidinst_serializer.py
  _pidinst_types.py
  routes.py 327-328
  tools.py
  wire.py 216
  apps/api/src/cora/equipment/adapters
  stub_doi_minter.py
  apps/api/src/cora/equipment/aggregates/asset
  events.py
  evolver.py
  state.py
  apps/api/src/cora/equipment/features/assign_asset_persistent_id
  __init__.py
  command.py
  decider.py
  handler.py
  route.py
  tool.py
  apps/api/src/cora/equipment/ports
  __init__.py
  doi_minter.py
  apps/api/src/cora/equipment/projections
  asset.py 330
Project Total  

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

…data assertion

The static test_projection_metadata assertion enumerates the full
subscribed_event_types frozenset. Slice F added AssetPersistentIdAssigned
to the projection writer but missed the corresponding test assertion update.
CI caught it; targeted local pytest runs did not cover this file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@xmap xmap merged commit e74031a into main Jun 5, 2026
4 checks passed
xmap added a commit that referenced this pull request Jun 5, 2026
…ction metadata assertion

The static test_projection_metadata assertion enumerates the full
subscribed_event_types frozenset. Phase 2 added FixturePersistentIdAssigned
to the projection writer but missed the corresponding test update. CI
caught it; targeted local pytest runs did not cover this file.

Same regression pattern as Asset slice F (PR #38 fixed by fe9430b);
need to add the existing tests/unit/<bc>/test_*_projection.py files
to the pre-push targeted run list when touching projection writers.

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