diff --git a/conformance/fixtures/runtime_governance_consistency.json b/conformance/fixtures/runtime_governance_consistency.json new file mode 100644 index 0000000..a1370cf --- /dev/null +++ b/conformance/fixtures/runtime_governance_consistency.json @@ -0,0 +1,10 @@ +{ + "name": "runtime-governance-consistency", + "version": "0.1", + "assertions": [ + "runtime lifecycle evaluation uses the governance engine attached to the relationship", + "a PAUSE lifecycle outcome records invocation status PAUSED", + "a BLOCK lifecycle outcome records invocation status BLOCKED", + "runtime governance decisions remain auditable relational events" + ] +} diff --git a/conformance/manifest.json b/conformance/manifest.json index 3836512..b634c51 100644 --- a/conformance/manifest.json +++ b/conformance/manifest.json @@ -18,6 +18,8 @@ "explicit_authorization_conditions", "pure_governance_evaluation", "explicit_governance_audit_recording", + "runtime_uses_relationship_governance", + "runtime_pause_and_block_are_distinct", "cross_boundary_disclosure_admission_derivation_separation", "consent_permission_intersection", "raw_execution_metadata_minimized", @@ -50,6 +52,7 @@ "fixtures/lifecycle_authority.json", "fixtures/purpose_bound_governance.json", "fixtures/time_conditions.json", - "fixtures/pure_governance_evaluation.json" + "fixtures/pure_governance_evaluation.json", + "fixtures/runtime_governance_consistency.json" ] } diff --git a/docs/runtime-governance-consistency.md b/docs/runtime-governance-consistency.md new file mode 100644 index 0000000..292c6da --- /dev/null +++ b/docs/runtime-governance-consistency.md @@ -0,0 +1,15 @@ +# Runtime governance consistency + +TRIA Runtime must evaluate execution against the governance engine attached to the `Relationship` being executed. + +A relationship may be constructed with caller-supplied governance behavior. Runtime must not silently replace that behavior with a fresh default `GovernanceEngine`, because doing so would create two governance realities for the same relationship. + +Lifecycle outcomes also retain their meaning in the invocation audit trail: + +- `ALLOW` may proceed to the remaining authorization checks. +- `PAUSE` records `InvocationResolved.status = "PAUSED"`. +- other non-allow lifecycle outcomes record `InvocationResolved.status = "BLOCKED"`. + +`PAUSE` and `BLOCK` are intentionally distinct. A paused relationship is not equivalent to a prohibited or dissolved relationship; it represents an operational state in which execution should wait without erasing the possibility of continuation. + +This build changes no event schema, projection version, or bundle format. It aligns Runtime with governance semantics already present in Core. diff --git a/src/tria/runtime.py b/src/tria/runtime.py index 9f25dba..ffb4ea4 100644 --- a/src/tria/runtime.py +++ b/src/tria/runtime.py @@ -5,7 +5,6 @@ from uuid import uuid4 from .core import Relationship -from .governance import GovernanceEngine from .types import Capability, GovernanceDecision, GovernanceOutcome @@ -71,11 +70,19 @@ class InvocationResult: class Runtime: """Model-agnostic boundary between governed relationship state and execution.""" + @staticmethod + def _resolution_status(outcome: GovernanceOutcome) -> str: + if outcome is GovernanceOutcome.PAUSE: + return "PAUSED" + return "BLOCKED" + def prepare(self, relationship: Relationship, request: InvocationRequest) -> InvocationPlan: relationship.record_invocation_proposed(request) decisions: list[GovernanceDecision] = [] - lifecycle_decision = GovernanceEngine().require_runtime_execution(relationship.state) + # Runtime must use the governance engine attached to the relationship. + # Creating a new engine here would bypass caller-supplied governance behavior. + lifecycle_decision = relationship._governance.require_runtime_execution(relationship.state) decisions.append(lifecycle_decision) relationship.record_governance_decision( lifecycle_decision, @@ -85,7 +92,12 @@ def prepare(self, relationship: Relationship, request: InvocationRequest) -> Inv check="lifecycle", ) if lifecycle_decision.outcome is not GovernanceOutcome.ALLOW: - relationship.record_invocation_resolution(request.requested_by, request.request_id, "BLOCKED", reason=lifecycle_decision.reason) + relationship.record_invocation_resolution( + request.requested_by, + request.request_id, + self._resolution_status(lifecycle_decision.outcome), + reason=lifecycle_decision.reason, + ) return InvocationPlan(request=request, outcome=lifecycle_decision.outcome, decisions=tuple(decisions), reason=lifecycle_decision.reason) for requirement in request.consent_requirements: diff --git a/tests/test_runtime_governance_consistency.py b/tests/test_runtime_governance_consistency.py new file mode 100644 index 0000000..167b99d --- /dev/null +++ b/tests/test_runtime_governance_consistency.py @@ -0,0 +1,68 @@ +from tria import ( + GovernanceDecision, + GovernanceEngine, + GovernanceOutcome, + InMemoryEventStore, + InvocationRequest, + LifecycleState, + Relationship, + Runtime, + Tria, +) + + +class BlockingRuntimeGovernance(GovernanceEngine): + def require_runtime_execution(self, state): + return GovernanceDecision( + GovernanceOutcome.BLOCK, + "test.runtime.injected", + "1", + "Injected governance blocks runtime execution.", + ) + + +def test_runtime_uses_relationship_governance_engine(): + rel = Relationship("relationship:custom", InMemoryEventStore(), governance=BlockingRuntimeGovernance()) + rel._commit("RelationshipCreated", "tria:system", {"participants": ["human:a", "agent:b"]}) + + plan = Runtime().prepare( + rel, + InvocationRequest(requested_by="agent:b", action="continue", target="executor:any"), + ) + + assert plan.outcome is GovernanceOutcome.BLOCK + assert plan.decisions[0].policy_id == "test.runtime.injected" + resolutions = [event for event in rel.events if event.event_type == "InvocationResolved"] + assert resolutions[-1].payload["status"] == "BLOCKED" + + +def test_resting_runtime_records_paused_not_blocked(): + rel = Tria().create_relationship(["human:a", "agent:b"]) + rel.grant_lifecycle_authority("tria:system", "human:a") + rel.transition("human:a", LifecycleState.ACTIVE) + rel.transition("human:a", LifecycleState.RESTING) + + plan = Runtime().prepare( + rel, + InvocationRequest(requested_by="agent:b", action="continue", target="executor:any"), + ) + + assert plan.outcome is GovernanceOutcome.PAUSE + resolutions = [event for event in rel.events if event.event_type == "InvocationResolved"] + assert resolutions[-1].payload["status"] == "PAUSED" + + +def test_dissolved_runtime_records_blocked(): + rel = Tria().create_relationship(["human:a", "agent:b"]) + rel.grant_lifecycle_authority("tria:system", "human:a") + rel.transition("human:a", LifecycleState.DISSOLVING) + rel.transition("human:a", LifecycleState.DISSOLVED) + + plan = Runtime().prepare( + rel, + InvocationRequest(requested_by="agent:b", action="continue", target="executor:any"), + ) + + assert plan.outcome is GovernanceOutcome.BLOCK + resolutions = [event for event in rel.events if event.event_type == "InvocationResolved"] + assert resolutions[-1].payload["status"] == "BLOCKED"