diff --git a/forge_cli/cli.py b/forge_cli/cli.py index b29b310..8296a63 100644 --- a/forge_cli/cli.py +++ b/forge_cli/cli.py @@ -455,7 +455,7 @@ def ref_cmd( raise typer.Exit(1) indent = None if compact else 2 - typer.echo(json.dumps(incident.to_ref_envelope(), indent=indent)) + typer.echo(json.dumps(incident.to_ref_envelope_model().to_dict(), indent=indent)) @app.command() diff --git a/forge_cli/mcp_server.py b/forge_cli/mcp_server.py index 1ba24c0..46e9a55 100644 --- a/forge_cli/mcp_server.py +++ b/forge_cli/mcp_server.py @@ -331,7 +331,7 @@ def forge_incident_ref(incident_id: str) -> str: if incident is None: return f"No incident found matching '{incident_id}'." - return json.dumps(incident.to_ref_envelope(), indent=2) + return json.dumps(incident.to_ref_envelope_model().to_dict(), indent=2) @mcp.tool() diff --git a/forge_cli/models.py b/forge_cli/models.py index 94ceac8..bb1a052 100644 --- a/forge_cli/models.py +++ b/forge_cli/models.py @@ -491,6 +491,23 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) +@dataclass(frozen=True) +class IncidentRefEnvelope: + """Typed Proofhouse shared-contract envelope for Forge IncidentRef output.""" + + contract_version: str + contract_name: str + producer_capability: str + producer_system: str + canonical_owner: str + issued_at: str + cache_policy: str + ref: IncidentRef + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @dataclass class Incident: id: str @@ -549,6 +566,10 @@ def to_ref(self) -> IncidentRef: def to_ref_envelope(self) -> dict[str, Any]: """Project this incident into a Proofhouse V0.1 ref envelope.""" + return self.to_ref_envelope_model().to_dict() + + def to_ref_envelope_model(self) -> IncidentRefEnvelope: + """Project this incident into a typed Proofhouse V0.1 ref envelope.""" return build_incident_ref_envelope(self) @classmethod @@ -652,15 +673,15 @@ def build_incident_ref(incident: Incident) -> IncidentRef: ) -def build_incident_ref_envelope(incident: Incident) -> dict[str, Any]: +def build_incident_ref_envelope(incident: Incident) -> IncidentRefEnvelope: """Wrap an IncidentRef in the Proofhouse shared-contract envelope.""" - return { - "contract_version": PROOFHOUSE_SHARED_CONTRACT_VERSION, - "contract_name": "IncidentRef", - "producer_capability": "forge", - "producer_system": FORGE_PRODUCER_SYSTEM, - "canonical_owner": "forge", - "issued_at": incident.timestamp, - "cache_policy": "summary_snapshot", - "ref": build_incident_ref(incident).to_dict(), - } + return IncidentRefEnvelope( + contract_version=PROOFHOUSE_SHARED_CONTRACT_VERSION, + contract_name="IncidentRef", + producer_capability="forge", + producer_system=FORGE_PRODUCER_SYSTEM, + canonical_owner="forge", + issued_at=incident.timestamp, + cache_policy="summary_snapshot", + ref=build_incident_ref(incident), + ) diff --git a/tests/test_models.py b/tests/test_models.py index 084f535..5ad6230 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,7 @@ PROOFHOUSE_SHARED_CONTRACT_VERSION, FailureType, Incident, + IncidentRefEnvelope, ISSUE_CLASS_VALUES, Severity, parse_observed_state, @@ -288,6 +289,17 @@ def test_incident_ref_envelope_shape(sample_data): assert envelope["ref"]["organization_id"] == FORGE_UNSCOPED_ORGANIZATION_ID +def test_incident_ref_envelope_has_typed_boundary_model(sample_data): + incident = Incident.from_dict(sample_data) + envelope = incident.to_ref_envelope_model() + + assert isinstance(envelope, IncidentRefEnvelope) + assert envelope.contract_name == "IncidentRef" + assert envelope.producer_capability == "forge" + assert envelope.ref.incident_id == sample_data["id"] + assert envelope.to_dict() == incident.to_ref_envelope() + + def test_structured_document_operations_incident_roundtrip(sample_data): data = sample_data.copy() data.update(