From 5cbec6348dfc4b2ec1c4a0670a3a6afcc680f32f Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 5 Jul 2026 00:01:56 +0300 Subject: [PATCH 1/2] Add the obligation-gate justification primitive (dormant) Introduce a shared, BC-agnostic obligation-gate primitive: a fail-closed "justification required at admission" check for a declared class of commands. It is the deontic dual of the Authorize port ("may you?" vs "have you accounted for yourself?"), and the first of two new governance gates for the "Four Gates, One Log" line. Why dormant: the declared-command set (COMMANDS_REQUIRING_JUSTIFICATION) ships empty, so require_justification can never raise for a real command and no existing behavior changes. Wiring a specific command's decider to the gate is a separate per-command commit; this lands only the mechanism, mirroring CORA's mechanism-first pattern. Design: a bare validated-string decider primitive (the reason-text bucket), not a value object with an aggregate; JUSTIFICATION_MAX_LENGTH is its own constant, not an alias of REASON_MAX_LENGTH. Kind-blind by construction: the surface takes no actor-kind argument, so a human and an agent are held to the identical precondition. Gate review: correctness/convention review (clean, safe to ship dormant) + R3 naming review. Both findings addressed in this commit: renamed the error class to JustificationRequiredError (family-noun primacy, matches the module's "command" vocabulary), and tightened the optional-branch test to pin trim + over-length-allowed together. Co-Authored-By: Claude --- apps/api/src/cora/shared/justification.py | 116 ++++++++++++++++ .../unit/infrastructure/test_justification.py | 131 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 apps/api/src/cora/shared/justification.py create mode 100644 apps/api/tests/unit/infrastructure/test_justification.py diff --git a/apps/api/src/cora/shared/justification.py b/apps/api/src/cora/shared/justification.py new file mode 100644 index 0000000000..b9ce5f1008 --- /dev/null +++ b/apps/api/src/cora/shared/justification.py @@ -0,0 +1,116 @@ +"""BC-agnostic obligation-gate primitive: a justification required at admission. + +The obligation gate answers "has this principal justified this action?" for a +declared class of commands. It is the deontic dual of authorization: where the +Authorize port answers "may you?", this answers "have you accounted for +yourself?", and it fails closed. This module is the shared, BC-agnostic core: +the bound, the validation, the declared-class membership check, and the decider +helper any command's decider can call. + +Where this sits +--------------- +This is a decider primitive, not a value object with its own aggregate. It +mirrors the way operator `reason` text is a bare validated string checked in the +decider (`cora.shared.text_bounds` + `validate_bounded_text`, the "bare-string +validation embedded in deciders" bucket documented in `cora.shared.bounded_text`) +rather than a per-aggregate VO. A justification is the same shape as a reason: +free text, bounded, trimmed, validated at the decider and at the API boundary. + +Kind-blindness (obligation-gate invariant) +------------------------------------------ +`require_justification` reads the command name and the supplied justification +text. It never reads actor kind: an autonomous agent must supply a justification +for a declared-class command exactly as a human operator must. There is no +actor-kind argument in this module's surface, so a caller cannot branch on kind +even by mistake. + +Declaration-only at this layer +------------------------------ +`COMMANDS_REQUIRING_JUSTIFICATION` is the declared class: the set of command +names for which a justification is a precondition of admission. It ships EMPTY. +Wiring a specific command's decider to call `require_justification`, and adding +that command to the declared set, is a separate change per command; this module +only supplies the mechanism. An empty declared set means the gate is inert until +a command opts in, so shipping this primitive changes no existing behavior. +""" + +JUSTIFICATION_MAX_LENGTH = 500 +"""Max length of an admission justification, after trim. + +Same envelope as `REASON_MAX_LENGTH`: a justification is operator/agent free +text of the same weight as a `reason`. Kept as its own constant (not an alias) +because the two answer different questions, a reason explains a completed +state change after the fact, a justification is a precondition checked before +admission, and either bound may be retuned without moving the other. +""" + + +COMMANDS_REQUIRING_JUSTIFICATION: frozenset[str] = frozenset() +"""The declared class: command names that require a justification at admission. + +Ships EMPTY. A command opts into the obligation gate by (1) adding its command +name here and (2) calling `require_justification` at the top of its decider. Kept +as a frozenset of canonical command-name strings, mirroring how other +cross-cutting command-name sets are declared, so membership is a pure fold with +no per-aggregate coupling. +""" + + +class JustificationRequiredError(Exception): + """A declared-class command was issued without a valid justification. + + Raised by `require_justification` when a command whose name is in + `COMMANDS_REQUIRING_JUSTIFICATION` carries an absent, blank, or over-length + justification. Fail-closed: the action is refused, not accompanied by a + deferred duty. Carries the command name so the API boundary can map it to a + 422 (missing/invalid required input) and quote which command was refused. + """ + + def __init__(self, command_name: str) -> None: + self.command_name = command_name + super().__init__( + f"command {command_name!r} requires a justification: " + f"supply non-empty text of at most {JUSTIFICATION_MAX_LENGTH} chars" + ) + + +def require_justification(command_name: str, justification: str | None) -> str | None: + """Obligation gate: enforce a justification for a declared-class command. + + Call at the top of a command's decider. Behavior: + + - If `command_name` is NOT in `COMMANDS_REQUIRING_JUSTIFICATION`, the gate + does not apply: return the justification unchanged (trimmed if present, + else None). A non-declared command may still carry an optional + justification, but it is not required. + - If `command_name` IS in the declared class, a justification is a + precondition: raise `JustificationRequiredError` when it is None, + blank-after-trim, or over-length; otherwise return the trimmed text. + + Reads only the command name and the text. Never reads actor kind: a human + and an agent are held to the identical precondition (the obligation-gate + kind-blindness invariant). + """ + required = command_name in COMMANDS_REQUIRING_JUSTIFICATION + + if justification is None: + if required: + raise JustificationRequiredError(command_name) + return None + + trimmed = justification.strip() + + if not required: + return trimmed or None + + if not trimmed or len(trimmed) > JUSTIFICATION_MAX_LENGTH: + raise JustificationRequiredError(command_name) + return trimmed + + +__all__ = [ + "COMMANDS_REQUIRING_JUSTIFICATION", + "JUSTIFICATION_MAX_LENGTH", + "JustificationRequiredError", + "require_justification", +] diff --git a/apps/api/tests/unit/infrastructure/test_justification.py b/apps/api/tests/unit/infrastructure/test_justification.py new file mode 100644 index 0000000000..bc2106692b --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_justification.py @@ -0,0 +1,131 @@ +"""Unit tests for `cora.shared.justification` (the obligation-gate primitive). + +Coverage: + - Declared set ships EMPTY (the gate is inert until a command opts in). + - Non-declared command: justification is optional; None stays None, text is + trimmed, blank-after-trim collapses to None. + - Declared command: justification is a fail-closed precondition (None, blank, + and over-length all raise; valid text is returned trimmed). + - Kind-blindness: the surface takes no actor-kind argument, so the same + (command, justification) yields the same result regardless of who calls it. + - The error carries the command name and maps cleanly to an API 422. +""" + +import pytest + +from cora.shared import justification as j +from cora.shared.justification import ( + COMMANDS_REQUIRING_JUSTIFICATION, + JUSTIFICATION_MAX_LENGTH, + JustificationRequiredError, + require_justification, +) + +# ---------- declared set ships empty (dormant primitive) ---------- + + +@pytest.mark.unit +def test_declared_set_ships_empty() -> None: + assert len(COMMANDS_REQUIRING_JUSTIFICATION) == 0 + + +# ---------- non-declared command: justification is optional ---------- + + +@pytest.mark.unit +def test_non_declared_command_none_justification_returns_none() -> None: + assert require_justification("some_command", None) is None + + +@pytest.mark.unit +def test_non_declared_command_trims_supplied_text() -> None: + assert require_justification("some_command", " because ") == "because" + + +@pytest.mark.unit +def test_non_declared_command_blank_text_collapses_to_none() -> None: + assert require_justification("some_command", " ") is None + + +@pytest.mark.unit +def test_non_declared_command_overlength_is_allowed_and_trimmed() -> None: + # Pins BOTH behaviors on the optional branch in one assertion: over-length is + # allowed (not required, so no ceiling) AND surrounding whitespace is trimmed. + long = "x" * (JUSTIFICATION_MAX_LENGTH + 50) + assert require_justification("some_command", f" {long} ") == long + + +# ---------- declared command: fail-closed precondition ---------- + + +@pytest.fixture +def declared(monkeypatch: pytest.MonkeyPatch) -> str: + """Add one command to the declared class for the duration of a test.""" + name = "gated_command" + monkeypatch.setattr(j, "COMMANDS_REQUIRING_JUSTIFICATION", frozenset({name})) + return name + + +@pytest.mark.unit +def test_declared_command_missing_justification_raises(declared: str) -> None: + with pytest.raises(JustificationRequiredError): + require_justification(declared, None) + + +@pytest.mark.unit +def test_declared_command_blank_justification_raises(declared: str) -> None: + with pytest.raises(JustificationRequiredError): + require_justification(declared, " ") + + +@pytest.mark.unit +def test_declared_command_overlength_justification_raises(declared: str) -> None: + with pytest.raises(JustificationRequiredError): + require_justification(declared, "x" * (JUSTIFICATION_MAX_LENGTH + 1)) + + +@pytest.mark.unit +def test_declared_command_valid_justification_returns_trimmed(declared: str) -> None: + assert require_justification(declared, " aligning the sample ") == "aligning the sample" + + +@pytest.mark.unit +def test_declared_command_justification_at_max_length_ok(declared: str) -> None: + text = "y" * JUSTIFICATION_MAX_LENGTH + assert require_justification(declared, text) == text + + +# ---------- the error carries the command name (for the API 422 mapping) ---------- + + +@pytest.mark.unit +def test_error_carries_command_name(declared: str) -> None: + try: + require_justification(declared, None) + except JustificationRequiredError as exc: + assert exc.command_name == declared + assert declared in str(exc) + else: # pragma: no cover - the call above must raise + pytest.fail("expected JustificationRequiredError") + + +# ---------- kind-blindness: no actor-kind argument exists in the surface ---------- + + +@pytest.mark.unit +def test_require_justification_takes_no_actor_kind_argument() -> None: + import inspect + + params = set(inspect.signature(require_justification).parameters) + assert params == {"command_name", "justification"} + # The obligation-gate kind-blindness invariant, enforced structurally: there + # is no parameter through which a caller could pass or branch on actor kind. + + +@pytest.mark.unit +def test_same_inputs_same_result_regardless_of_caller(declared: str) -> None: + # No caller identity is threaded in, so two "different principals" issuing + # the identical (command, justification) get byte-identical results. + a = require_justification(declared, " same reason ") + b = require_justification(declared, " same reason ") + assert a == b == "same reason" From f01d421a56a103f6f36fd87c403114602281e7c5 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 7 Jul 2026 16:27:28 +0300 Subject: [PATCH 2/2] Wire the obligation gate: justification-required abort_run (Gate III) Make the dormant justification primitive a live gate. AbortRun joins COMMANDS_REQUIRING_JUSTIFICATION, so the abort_run decider calls require_justification FIRST (admission is the outer precondition): an abort without a non-empty, bounded justification is refused with JustificationRequiredError (HTTP 422), fail-closed, before any state / reason / status check. Kind-blind: the primitive reads only the command name + text, never actor kind, so a human and an agent (and the autonomous conductors) are held to the identical precondition. This is the paper's generativity punchline (Four Gates, One Log). The obligation gate's MECHANISM is conceded prior art (XACML pre-obligations / UCON, per the novelty dossier); its value is the cheapness, so the marginal-cost row is the strongest data point in the set: 1 fold + 1 decider arm, 0 new aggregates, 0 subscribers, 0 new projections. The attestation-failure meter + auto-hold subscriber (the richer half) is DEFERRED (documented), because it would re-demonstrate shared-hold discharge already shown by the kill-switch and consequence gate. Target = abort_run: the archetypal "account for yourself before a consequential act" (aborting a running experiment destroys in-progress data). It already carries a post-hoc `reason` field, so adding `justification` as an admission precondition cleanly separates the two deontic objects (reason describes the abort on the RunAborted event; justification accounts for taking it, and is validated but not persisted in v1). Note abort_run was REJECTED as the consequence-gate target (you never hold an emergency for a co-sign) but is right for obligation (a synchronous justify-in-the-same-call is fine for an emergency): same command, different gates, different deontic weights. Kind-blindness demonstrated in shipped code across human AND machine: putting the gate on abort_run forced the four autonomous abort callers to justify too, and they do (failure-derived text): EdgeConductor (compute failed / cancelled mid-flight), the run-phase conductor, and the RunSupervisor. The machine accounts for itself exactly as an operator does. Wiring: justification field on the AbortRun command + REST route model + MCP tool (openapi regenerated); a 422 handler in run routes (distinct from the BC's 400 for domain-invalid values and 409 for transition guards, matching the 422-for-unprocessable convention in Access/Federation/Operation). Gate review (3 agents: architecture / test-coverage / cross-BC): 0 P0, 2 P1 + P2s, all addressed. P1s: an MCP-surface refusal test (the gate fires through MCP too), and the route's 422 responses= doc + ErrorResponse model. P2s: a coupling test pinning AbortRun stays in the declared class (else the gate silently goes inert); stale "ships EMPTY" docstrings corrected; endpoint reason/uuid-validation tests given a justification so they isolate their intent from the gate; the intra-BC 400-vs-422 rationale documented; redundant MCP Field default dropped. Watch (deferred): a fitness test binding the two-place command-name coupling lands at the rule-of-three (2nd opt-in). Verified: pyright + ruff + tach clean, openapi drift clean, 43888 unit + architecture + contract tests green (incl. the 6 that the gate correctly broke on unjustified aborts, now fixed at their source/setup callers). Co-Authored-By: Claude --- apps/api/openapi.json | 22 ++- apps/api/src/cora/api/_edge_conductor.py | 7 + apps/api/src/cora/api/_run_phase_conduct.py | 3 + apps/api/src/cora/api/_run_supervisor.py | 9 +- .../cora/run/features/abort_run/command.py | 10 ++ .../cora/run/features/abort_run/decider.py | 17 +++ .../src/cora/run/features/abort_run/route.py | 21 ++- .../src/cora/run/features/abort_run/tool.py | 14 ++ apps/api/src/cora/run/routes.py | 24 ++++ apps/api/src/cora/shared/justification.py | 36 +++-- .../tests/contract/test_abort_run_endpoint.py | 78 ++++++++-- .../tests/contract/test_abort_run_mcp_tool.py | 33 ++++- .../contract/test_adjust_run_endpoint.py | 6 +- .../test_append_observation_endpoint.py | 2 +- .../contract/test_complete_run_endpoint.py | 5 +- .../test_promote_dataset_guards_endpoint.py | 5 +- .../contract/test_resume_run_endpoint.py | 8 +- .../tests/contract/test_stop_run_endpoint.py | 5 +- .../contract/test_truncate_run_endpoint.py | 5 +- .../test_2bm_run_debriefer_aborted.py | 1 + .../test_list_runs_handler_postgres.py | 6 +- .../test_promote_dataset_handler_postgres.py | 6 +- ..._run_pause_transitions_handler_postgres.py | 6 +- .../test_run_transitions_handler_postgres.py | 6 +- .../unit/infrastructure/test_justification.py | 11 +- .../tests/unit/run/test_abort_run_decider.py | 73 ++++++++-- .../run/test_abort_run_decider_properties.py | 35 ++++- .../tests/unit/run/test_abort_run_handler.py | 42 +++++- .../run/test_abort_run_obligation_gate.py | 135 ++++++++++++++++++ 29 files changed, 566 insertions(+), 65 deletions(-) create mode 100644 apps/api/tests/unit/run/test_abort_run_obligation_gate.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7610e330ea..4c486ee8b3 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -82,6 +82,19 @@ "description": "Optional Decision id that justified this abort (most commonly an OperatorAbortDecision or EquipmentAbortDecision per RunDebrief's 5-value choice enum). Maps to `prov:wasInformedBy` at the future PROV-O export adapter. NOT verified at the write path (eventual-consistency stance). Operators can record ad-hoc / emergency aborts without a Decision (Decision\u2192Run linkage).", "title": "Decided By Decision Id" }, + "justification": { + "anyOf": [ + { + "maxLength": 500, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Obligation gate (Gate III): AbortRun requires an admission justification accounting for why this consequential action is taken. Fail-closed (absent / blank / over-length -> 422). Distinct from `reason`, which is post-hoc text on the RunAborted event; the justification is the precondition of admission. Kind-blind (a human and an agent supply it identically).", + "title": "Justification" + }, "reason": { "description": "Free-form reason for the abort (1-500 chars after trimming). Today the field is unstructured; structured taxonomy is future-additive.", "maxLength": 500, @@ -40592,7 +40605,14 @@ "description": "Run is not in `Running` status (abort requires `Running` today; aborting a `Completed` or `Aborted` run raises), OR a concurrent write to the same run stream conflicted (optimistic concurrency)." }, "422": { - "description": "Path parameter or request body failed schema validation." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Path parameter or request body failed schema validation, OR the obligation gate (Gate III) refused the abort because no valid justification was supplied (absent / blank / over-length)." } }, "summary": "Mark an existing Run as aborted (emergency-exit terminal)", diff --git a/apps/api/src/cora/api/_edge_conductor.py b/apps/api/src/cora/api/_edge_conductor.py index 930db64501..210640d9d9 100644 --- a/apps/api/src/cora/api/_edge_conductor.py +++ b/apps/api/src/cora/api/_edge_conductor.py @@ -274,6 +274,10 @@ async def conduct( AbortRun( run_id=run_id, reason="cancelled mid-compute", + # Obligation gate (Gate III): AbortRun requires a justification + # even for machine-initiated aborts (kind-blind). The conductor + # accounts for itself with the mechanical cause. + justification="edge-conductor: compute conduct cancelled mid-flight", actuation_kind=None, producing_job_id=str(in_flight[0]) if in_flight else None, ), @@ -382,6 +386,9 @@ async def _issue_abort( AbortRun( run_id=run_id, reason=outcome.failure or "compute conduct failed", + # Obligation gate (Gate III): kind-blind, so the conductor + # justifies its own abort with the mechanical failure cause. + justification=f"edge-conductor: {outcome.failure or 'compute conduct failed'}", actuation_kind=( outcome.actuation_kind.value if outcome.actuation_kind is not None else None ), diff --git a/apps/api/src/cora/api/_run_phase_conduct.py b/apps/api/src/cora/api/_run_phase_conduct.py index 5d3fd4d169..96366bdb06 100644 --- a/apps/api/src/cora/api/_run_phase_conduct.py +++ b/apps/api/src/cora/api/_run_phase_conduct.py @@ -165,6 +165,9 @@ async def conduct_phase_then_complete_run( AbortRun( run_id=run_id, reason=_abort_reason(result.failure), + # Obligation gate (Gate III): kind-blind, so the phase + # conductor justifies its abort with the mechanical cause. + justification=f"run-phase-conduct: {_abort_reason(result.failure)}", actuation_kind=result.actuation_kind, ), **envelope, diff --git a/apps/api/src/cora/api/_run_supervisor.py b/apps/api/src/cora/api/_run_supervisor.py index f542a30dec..d6e957cb50 100644 --- a/apps/api/src/cora/api/_run_supervisor.py +++ b/apps/api/src/cora/api/_run_supervisor.py @@ -752,7 +752,14 @@ async def _issue_abort( benign no-op.""" try: await abort_run( - AbortRun(run_id=run_id, reason=reason, decided_by_decision_id=decision_id), + AbortRun( + run_id=run_id, + reason=reason, + # Obligation gate (Gate III): kind-blind, so the supervisor agent + # justifies its autonomous abort with the same cause it acts on. + justification=f"run-supervisor: {reason}", + decided_by_decision_id=decision_id, + ), principal_id=RUN_SUPERVISOR_AGENT_ID, correlation_id=deps.id_generator.new_id(), surface_id=NIL_SENTINEL_ID, diff --git a/apps/api/src/cora/run/features/abort_run/command.py b/apps/api/src/cora/run/features/abort_run/command.py index d44d0f374d..45ecd69be9 100644 --- a/apps/api/src/cora/run/features/abort_run/command.py +++ b/apps/api/src/cora/run/features/abort_run/command.py @@ -34,6 +34,15 @@ class AbortRun: cancelled / timed-out job (None for operator aborts). The kind is folded onto `Run.actuation_kind` so even a failed conduct taints any Dataset that references the Run. + + `justification` is the obligation gate (Gate III) input: since AbortRun is in + `COMMANDS_REQUIRING_JUSTIFICATION`, the decider requires a non-empty, bounded + justification at admission (fail-closed if absent/blank/over-length). Distinct + from `reason`: `reason` is post-hoc free text describing the abort that lands on + the RunAborted event; `justification` is the admission PRECONDITION accounting + for why the principal is entitled to take this consequential action. Kind-blind + (a human and an agent supply it identically). Optional on the dataclass so + non-declared callers are unaffected; the gate makes it mandatory for AbortRun. """ run_id: UUID @@ -41,3 +50,4 @@ class AbortRun: decided_by_decision_id: UUID | None = None actuation_kind: str | None = None producing_job_id: str | None = None + justification: str | None = None diff --git a/apps/api/src/cora/run/features/abort_run/decider.py b/apps/api/src/cora/run/features/abort_run/decider.py index 97d927ec69..25edf1349e 100644 --- a/apps/api/src/cora/run/features/abort_run/decider.py +++ b/apps/api/src/cora/run/features/abort_run/decider.py @@ -7,11 +7,24 @@ Aborted | Stopped) raises `RunCannotAbortError`; re-aborting an `Aborted` Run raises (strict-not-idempotent). +## Obligation gate (Gate III) + +AbortRun is in `COMMANDS_REQUIRING_JUSTIFICATION`, so `require_justification` is +called FIRST (admission is the outer precondition): an abort without a non-empty, +bounded justification is refused with `JustificationRequiredError` (HTTP 422) +before any state/status check. Fail-closed and kind-blind (the helper reads only +the command name + text, never actor kind). The justification is the admission +account for taking this consequential action; it is distinct from the post-hoc +`reason` that lands on the RunAborted event and stays a pure decider input (no +I/O, so no handler pre-load needed, unlike the consequence gate's coverage lookup). + `reason` validation goes through the `RunAbortReason` VO (which calls the shared `validate_bounded_text` helper). The on-the-wire payload in `RunAborted.reason` carries the trimmed string. Invariants: + - Declared-class command without a valid justification + -> JustificationRequiredError - State must not be None -> RunNotFoundError - command.reason must be 1-500 chars after trimming -> InvalidRunAbortReasonError @@ -30,9 +43,12 @@ RunStatus, ) from cora.run.features.abort_run.command import AbortRun +from cora.shared.justification import require_justification _ABORTABLE_STATUSES: tuple[RunStatus, ...] = (RunStatus.RUNNING, RunStatus.HELD) +_COMMAND_NAME = "AbortRun" + def decide( state: Run | None, @@ -41,6 +57,7 @@ def decide( now: datetime, ) -> list[RunAborted]: """Decide the events produced by aborting an existing Run.""" + require_justification(_COMMAND_NAME, command.justification) if state is None: raise RunNotFoundError(command.run_id) reason = RunAbortReason(command.reason) diff --git a/apps/api/src/cora/run/features/abort_run/route.py b/apps/api/src/cora/run/features/abort_run/route.py index 1565d02891..e02119110c 100644 --- a/apps/api/src/cora/run/features/abort_run/route.py +++ b/apps/api/src/cora/run/features/abort_run/route.py @@ -18,6 +18,7 @@ ) from cora.run.features.abort_run.command import AbortRun from cora.run.features.abort_run.handler import Handler +from cora.shared.justification import JUSTIFICATION_MAX_LENGTH from cora.shared.text_bounds import REASON_MAX_LENGTH @@ -34,6 +35,18 @@ class AbortRunRequest(BaseModel): "future-additive." ), ) + justification: str | None = Field( + default=None, + max_length=JUSTIFICATION_MAX_LENGTH, + description=( + "Obligation gate (Gate III): AbortRun requires an admission " + "justification accounting for why this consequential action is " + "taken. Fail-closed (absent / blank / over-length -> 422). Distinct " + "from `reason`, which is post-hoc text on the RunAborted event; the " + "justification is the precondition of admission. Kind-blind (a human " + "and an agent supply it identically)." + ), + ) decided_by_decision_id: UUID | None = Field( default=None, description=( @@ -82,7 +95,12 @@ def _get_handler(request: Request) -> Handler: ), }, status.HTTP_422_UNPROCESSABLE_CONTENT: { - "description": "Path parameter or request body failed schema validation.", + "model": ErrorResponse, + "description": ( + "Path parameter or request body failed schema validation, OR the " + "obligation gate (Gate III) refused the abort because no valid " + "justification was supplied (absent / blank / over-length)." + ), }, }, summary="Mark an existing Run as aborted (emergency-exit terminal)", @@ -99,6 +117,7 @@ async def post_runs_abort( AbortRun( run_id=run_id, reason=body.reason, + justification=body.justification, decided_by_decision_id=body.decided_by_decision_id, ), principal_id=principal_id, diff --git a/apps/api/src/cora/run/features/abort_run/tool.py b/apps/api/src/cora/run/features/abort_run/tool.py index a7b3f8ab68..e8947a5b80 100644 --- a/apps/api/src/cora/run/features/abort_run/tool.py +++ b/apps/api/src/cora/run/features/abort_run/tool.py @@ -12,6 +12,7 @@ from cora.infrastructure.routing import get_mcp_surface_id from cora.run.features.abort_run.command import AbortRun from cora.run.features.abort_run.handler import Handler +from cora.shared.justification import JUSTIFICATION_MAX_LENGTH from cora.shared.text_bounds import REASON_MAX_LENGTH @@ -42,6 +43,18 @@ async def abort_run_tool( # pyright: ignore[reportUnusedFunction] description=("Free-form reason for the abort (1-500 chars after trimming)."), ), ], + justification: Annotated[ + str | None, + Field( + max_length=JUSTIFICATION_MAX_LENGTH, + description=( + "Obligation gate (Gate III): AbortRun requires an admission " + "justification accounting for this consequential action. " + "Fail-closed (absent / blank / over-length -> refused). " + "Distinct from reason (post-hoc event text). Kind-blind." + ), + ), + ] = None, decided_by_decision_id: Annotated[ UUID | None, Field( @@ -60,6 +73,7 @@ async def abort_run_tool( # pyright: ignore[reportUnusedFunction] AbortRun( run_id=run_id, reason=reason, + justification=justification, decided_by_decision_id=decided_by_decision_id, ), principal_id=get_mcp_principal_id(ctx), diff --git a/apps/api/src/cora/run/routes.py b/apps/api/src/cora/run/routes.py index 4a232e66b1..96cccd20c4 100644 --- a/apps/api/src/cora/run/routes.py +++ b/apps/api/src/cora/run/routes.py @@ -114,6 +114,7 @@ stop_run, truncate_run, ) +from cora.shared.justification import JustificationRequiredError async def _handle_validation_error(request: Request, exc: Exception) -> JSONResponse: @@ -125,6 +126,27 @@ async def _handle_validation_error(request: Request, exc: Exception) -> JSONResp ) +async def _handle_justification_required(request: Request, exc: Exception) -> JSONResponse: + """422 handler for the obligation gate (Gate III). + + A declared-class command (AbortRun) was issued without a valid justification. + 422 (unprocessable content) rather than 400: the request is well-formed but + fails the admission precondition of accounting for the action, matching the + 422-for-unprocessable convention the Access / Federation / Operation BCs use. + + Intra-BC distinction (deliberate, not drift): a bad `reason` on the same + command maps to 400 via `_handle_validation_error` (a domain-invariant on a + supplied value), while a missing/blank `justification` maps here to 422 (an + unmet admission precondition). So on one abort call an over-length reason is + 400 but an over-length justification is 422; the two answer different questions + (domain validity of a value vs. accountability for the act).""" + _ = request + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + content={"detail": str(exc)}, + ) + + async def _handle_unauthorized(request: Request, exc: Exception) -> JSONResponse: _ = request reason = exc.reason if isinstance(exc, UnauthorizedError) else str(exc) @@ -210,6 +232,8 @@ def register_run_routes(app: FastAPI) -> None: InvalidInputDatasetsError, ): app.add_exception_handler(validation_cls, _handle_validation_error) + # Obligation gate (Gate III): missing/invalid justification -> 422. + app.add_exception_handler(JustificationRequiredError, _handle_justification_required) for not_found_cls in (RunNotFoundError,): app.add_exception_handler(not_found_cls, _handle_not_found) for already_exists_cls in (RunAlreadyExistsError,): diff --git a/apps/api/src/cora/shared/justification.py b/apps/api/src/cora/shared/justification.py index b9ce5f1008..8006ad1731 100644 --- a/apps/api/src/cora/shared/justification.py +++ b/apps/api/src/cora/shared/justification.py @@ -27,11 +27,21 @@ Declaration-only at this layer ------------------------------ `COMMANDS_REQUIRING_JUSTIFICATION` is the declared class: the set of command -names for which a justification is a precondition of admission. It ships EMPTY. -Wiring a specific command's decider to call `require_justification`, and adding -that command to the declared set, is a separate change per command; this module -only supplies the mechanism. An empty declared set means the gate is inert until -a command opts in, so shipping this primitive changes no existing behavior. +names for which a justification is a precondition of admission. A command opts in +by adding its name to the set AND calling `require_justification` at the top of +its decider; this module only supplies the mechanism. v1 declares `AbortRun` +(aborting a running experiment). Commands not in the set are unaffected: an empty +or non-membership case leaves the gate inert for them. + +Not persisted at this layer (v1 deferral) +----------------------------------------- +`require_justification` VALIDATES the justification and returns the trimmed text, +but v1 does NOT persist it: the gated command's event (e.g. RunAborted) does not +carry the justification, so it is an admission precondition only, not part of the +log. This is a deliberate scope cut (the `JustificationSupplied` event / the EG5 +"who justified what" replay story is the deferred richer half). A command that +wants the justification on its event captures the returned value; abort_run does +not, by design. """ JUSTIFICATION_MAX_LENGTH = 500 @@ -45,14 +55,18 @@ """ -COMMANDS_REQUIRING_JUSTIFICATION: frozenset[str] = frozenset() +COMMANDS_REQUIRING_JUSTIFICATION: frozenset[str] = frozenset({"AbortRun"}) """The declared class: command names that require a justification at admission. -Ships EMPTY. A command opts into the obligation gate by (1) adding its command -name here and (2) calling `require_justification` at the top of its decider. Kept -as a frozenset of canonical command-name strings, mirroring how other -cross-cutting command-name sets are declared, so membership is a pure fold with -no per-aggregate coupling. +A command opts into the obligation gate by (1) adding its command name here and +(2) calling `require_justification` at the top of its decider. Kept as a frozenset +of canonical command-name strings, mirroring how other cross-cutting command-name +sets are declared, so membership is a pure fold with no per-aggregate coupling. + +v1 membership: `AbortRun` (aborting a running experiment destroys in-progress +data, the archetypal "account for yourself before a consequential act"). A +justification is the admission precondition; the abort's post-hoc `reason` is a +separate field on the RunAborted event. """ diff --git a/apps/api/tests/contract/test_abort_run_endpoint.py b/apps/api/tests/contract/test_abort_run_endpoint.py index 22ec640adb..1ec3424d02 100644 --- a/apps/api/tests/contract/test_abort_run_endpoint.py +++ b/apps/api/tests/contract/test_abort_run_endpoint.py @@ -18,7 +18,10 @@ def test_post_abort_run_returns_204_from_running_state() -> None: with TestClient(create_app()) as client: run_id = seed_run_upstream_chain(client) - response = client.post(f"/runs/{run_id}/abort", json={"reason": "detector overheating"}) + response = client.post( + f"/runs/{run_id}/abort", + json={"reason": "detector overheating", "justification": "operator: aborting for test"}, + ) assert response.status_code == 204 @@ -27,7 +30,13 @@ def test_post_abort_run_round_trips_into_get_run_response() -> None: """End-to-end: abort + get → status=Aborted.""" with TestClient(create_app()) as client: run_id = seed_run_upstream_chain(client) - client.post(f"/runs/{run_id}/abort", json={"reason": "beam dump unscheduled"}) + client.post( + f"/runs/{run_id}/abort", + json={ + "reason": "beam dump unscheduled", + "justification": "operator: aborting for test", + }, + ) response = client.get(f"/runs/{run_id}") assert response.status_code == 200 @@ -40,7 +49,13 @@ def test_post_abort_run_returns_204_from_held_state() -> None: with TestClient(create_app()) as client: run_id = seed_run_upstream_chain(client) client.post(f"/runs/{run_id}/hold") - response = client.post(f"/runs/{run_id}/abort", json={"reason": "emergency during hold"}) + response = client.post( + f"/runs/{run_id}/abort", + json={ + "reason": "emergency during hold", + "justification": "operator: aborting for test", + }, + ) assert response.status_code == 204 @@ -48,7 +63,10 @@ def test_post_abort_run_returns_204_from_held_state() -> None: def test_post_abort_run_returns_404_when_run_does_not_exist() -> None: missing_id = str(uuid4()) with TestClient(create_app()) as client: - response = client.post(f"/runs/{missing_id}/abort", json={"reason": "X"}) + response = client.post( + f"/runs/{missing_id}/abort", + json={"reason": "X", "justification": "operator: aborting for test"}, + ) assert response.status_code == 404 @@ -57,9 +75,15 @@ def test_post_abort_run_returns_409_when_already_aborted() -> None: """Strict-not-idempotent: re-aborting raises 409.""" with TestClient(create_app()) as client: run_id = seed_run_upstream_chain(client) - first = client.post(f"/runs/{run_id}/abort", json={"reason": "first abort"}) + first = client.post( + f"/runs/{run_id}/abort", + json={"reason": "first abort", "justification": "operator: aborting for test"}, + ) assert first.status_code == 204 - second = client.post(f"/runs/{run_id}/abort", json={"reason": "second abort"}) + second = client.post( + f"/runs/{run_id}/abort", + json={"reason": "second abort", "justification": "operator: aborting for test"}, + ) assert second.status_code == 409 assert "Running" in second.json()["detail"] @@ -71,16 +95,43 @@ def test_post_abort_run_returns_409_when_completed() -> None: run_id = seed_run_upstream_chain(client) complete = client.post(f"/runs/{run_id}/complete") assert complete.status_code == 204 - response = client.post(f"/runs/{run_id}/abort", json={"reason": "X"}) + response = client.post( + f"/runs/{run_id}/abort", + json={"reason": "X", "justification": "operator: aborting for test"}, + ) assert response.status_code == 409 assert "Completed" in response.json()["detail"] +@pytest.mark.contract +def test_post_abort_run_without_justification_returns_422() -> None: + """Obligation gate (Gate III): an abort with no justification is refused 422 + (fail-closed), even on an otherwise-valid Running run.""" + with TestClient(create_app()) as client: + run_id = seed_run_upstream_chain(client) + response = client.post(f"/runs/{run_id}/abort", json={"reason": "detector overheating"}) + assert response.status_code == 422 + + +@pytest.mark.contract +def test_post_abort_run_with_blank_justification_returns_422() -> None: + """A whitespace-only justification passes Pydantic min but the decider trims + and rejects it (fail-closed obligation gate).""" + with TestClient(create_app()) as client: + run_id = seed_run_upstream_chain(client) + response = client.post( + f"/runs/{run_id}/abort", + json={"reason": "detector overheating", "justification": " "}, + ) + assert response.status_code == 422 + + @pytest.mark.contract def test_post_abort_run_rejects_empty_reason_with_422() -> None: with TestClient(create_app()) as client: run_id = seed_run_upstream_chain(client) - response = client.post(f"/runs/{run_id}/abort", json={"reason": ""}) + # Valid justification so this isolates the reason validation, not the gate. + response = client.post(f"/runs/{run_id}/abort", json={"reason": "", "justification": "j"}) assert response.status_code == 422 @@ -89,7 +140,10 @@ def test_post_abort_run_rejects_whitespace_only_reason_with_400() -> None: """Whitespace passes Pydantic but the decider trims and rejects.""" with TestClient(create_app()) as client: run_id = seed_run_upstream_chain(client) - response = client.post(f"/runs/{run_id}/abort", json={"reason": " "}) + response = client.post( + f"/runs/{run_id}/abort", + json={"reason": " ", "justification": "operator: aborting for test"}, + ) assert response.status_code == 400 assert "abort reason" in response.json()["detail"].lower() @@ -98,12 +152,14 @@ def test_post_abort_run_rejects_whitespace_only_reason_with_400() -> None: def test_post_abort_run_rejects_too_long_reason_with_422() -> None: with TestClient(create_app()) as client: run_id = seed_run_upstream_chain(client) - response = client.post(f"/runs/{run_id}/abort", json={"reason": "x" * 501}) + response = client.post( + f"/runs/{run_id}/abort", json={"reason": "x" * 501, "justification": "j"} + ) assert response.status_code == 422 @pytest.mark.contract def test_post_abort_run_rejects_invalid_path_uuid_with_422() -> None: with TestClient(create_app()) as client: - response = client.post("/runs/not-a-uuid/abort", json={"reason": "X"}) + response = client.post("/runs/not-a-uuid/abort", json={"reason": "X", "justification": "j"}) assert response.status_code == 422 diff --git a/apps/api/tests/contract/test_abort_run_mcp_tool.py b/apps/api/tests/contract/test_abort_run_mcp_tool.py index af13f6bc5f..8b85257c50 100644 --- a/apps/api/tests/contract/test_abort_run_mcp_tool.py +++ b/apps/api/tests/contract/test_abort_run_mcp_tool.py @@ -80,6 +80,7 @@ def test_mcp_abort_run_tool_succeeds_on_happy_path() -> None: "arguments": { "run_id": run_id, "reason": "detector overheating", + "justification": "operator: aborting for test", }, }, }, @@ -89,6 +90,31 @@ def test_mcp_abort_run_tool_succeeds_on_happy_path() -> None: assert body["result"]["isError"] is False +@pytest.mark.contract +def test_mcp_abort_run_tool_returns_iserror_when_justification_missing() -> None: + """Obligation gate (Gate III) fires through the MCP surface too: an abort with + no justification is refused (isError), kind-blind to the agent caller.""" + with TestClient(create_app()) as client: + run_id = _setup_full_run(client) + headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": { + "name": "abort_run", + "arguments": {"run_id": run_id, "reason": "detector overheating"}, + }, + }, + headers=headers, + ) + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + assert "justification" in body["result"]["content"][0]["text"].lower() + + @pytest.mark.contract def test_mcp_abort_run_tool_returns_iserror_for_unknown_run() -> None: with TestClient(create_app()) as client: @@ -104,6 +130,7 @@ def test_mcp_abort_run_tool_returns_iserror_for_unknown_run() -> None: "arguments": { "run_id": str(uuid4()), "reason": "X", + "justification": "operator: aborting for test", }, }, }, @@ -118,7 +145,10 @@ def test_mcp_abort_run_tool_returns_iserror_for_unknown_run() -> None: def test_mcp_abort_run_tool_returns_iserror_when_already_aborted() -> None: with TestClient(create_app()) as client: run_id = _setup_full_run(client) - first = client.post(f"/runs/{run_id}/abort", json={"reason": "first"}) + first = client.post( + f"/runs/{run_id}/abort", + json={"reason": "first", "justification": "operator: aborting for test"}, + ) assert first.status_code == 204 headers = open_session(client) response = client.post( @@ -132,6 +162,7 @@ def test_mcp_abort_run_tool_returns_iserror_when_already_aborted() -> None: "arguments": { "run_id": run_id, "reason": "second", + "justification": "operator: aborting for test", }, }, }, diff --git a/apps/api/tests/contract/test_adjust_run_endpoint.py b/apps/api/tests/contract/test_adjust_run_endpoint.py index 442a92d135..cb8f588dea 100644 --- a/apps/api/tests/contract/test_adjust_run_endpoint.py +++ b/apps/api/tests/contract/test_adjust_run_endpoint.py @@ -212,7 +212,11 @@ def test_post_adjust_run_returns_409_for_each_terminal_state( if transition == "complete": client.post(f"/runs/{run_id}/complete") else: - client.post(f"/runs/{run_id}/{transition}", json={"reason": "test"}) + body = {"reason": "test"} + if transition == "abort": + # Obligation gate (Gate III): abort requires a justification. + body["justification"] = "operator: terminal-state setup" + client.post(f"/runs/{run_id}/{transition}", json=body) response = client.post( f"/runs/{run_id}/adjust", diff --git a/apps/api/tests/contract/test_append_observation_endpoint.py b/apps/api/tests/contract/test_append_observation_endpoint.py index 60aecbe10e..0420a53955 100644 --- a/apps/api/tests/contract/test_append_observation_endpoint.py +++ b/apps/api/tests/contract/test_append_observation_endpoint.py @@ -158,7 +158,7 @@ def test_post_readings_returns_404_for_unknown_run() -> None: "terminal_call", [ ("complete", {}), - ("abort", {"reason": "operator stop"}), + ("abort", {"reason": "operator stop", "justification": "operator: terminal-state setup"}), ("stop", {"reason": "controlled exit"}), ("truncate", {"reason": "process crashed"}), ], diff --git a/apps/api/tests/contract/test_complete_run_endpoint.py b/apps/api/tests/contract/test_complete_run_endpoint.py index 8002aaff35..14487209fc 100644 --- a/apps/api/tests/contract/test_complete_run_endpoint.py +++ b/apps/api/tests/contract/test_complete_run_endpoint.py @@ -100,7 +100,10 @@ def test_post_complete_run_returns_409_when_aborted() -> None: """Aborted is terminal — cannot complete from Aborted.""" with TestClient(create_app()) as client: run_id = _setup_full_run(client) - abort = client.post(f"/runs/{run_id}/abort", json={"reason": "early test abort"}) + abort = client.post( + f"/runs/{run_id}/abort", + json={"reason": "early test abort", "justification": "operator: aborting for test"}, + ) assert abort.status_code == 204 response = client.post(f"/runs/{run_id}/complete") assert response.status_code == 409 diff --git a/apps/api/tests/contract/test_promote_dataset_guards_endpoint.py b/apps/api/tests/contract/test_promote_dataset_guards_endpoint.py index 44966bd328..4ee9983b80 100644 --- a/apps/api/tests/contract/test_promote_dataset_guards_endpoint.py +++ b/apps/api/tests/contract/test_promote_dataset_guards_endpoint.py @@ -80,7 +80,10 @@ def _start_run_and_finish(client: TestClient, *, end_state: str) -> str: if end_state == "Completed": resp = client.post(f"/runs/{run_id}/complete", json={}) elif end_state == "Aborted": - resp = client.post(f"/runs/{run_id}/abort", json={"reason": "operator stop"}) + resp = client.post( + f"/runs/{run_id}/abort", + json={"reason": "operator stop", "justification": "operator: aborting for test"}, + ) elif end_state == "Stopped": resp = client.post(f"/runs/{run_id}/stop", json={"reason": "controlled exit"}) elif end_state == "Truncated": diff --git a/apps/api/tests/contract/test_resume_run_endpoint.py b/apps/api/tests/contract/test_resume_run_endpoint.py index d1a908d687..7b1ec76e22 100644 --- a/apps/api/tests/contract/test_resume_run_endpoint.py +++ b/apps/api/tests/contract/test_resume_run_endpoint.py @@ -112,7 +112,13 @@ def test_post_resume_run_returns_409_when_aborted() -> None: with TestClient(create_app()) as client: run_id = _setup_full_run(client) client.post(f"/runs/{run_id}/hold") - client.post(f"/runs/{run_id}/abort", json={"reason": "emergency during hold"}) + client.post( + f"/runs/{run_id}/abort", + json={ + "reason": "emergency during hold", + "justification": "operator: aborting for test", + }, + ) response = client.post(f"/runs/{run_id}/resume") assert response.status_code == 409 assert "Aborted" in response.json()["detail"] diff --git a/apps/api/tests/contract/test_stop_run_endpoint.py b/apps/api/tests/contract/test_stop_run_endpoint.py index 3bbafdb41a..329dd0a18b 100644 --- a/apps/api/tests/contract/test_stop_run_endpoint.py +++ b/apps/api/tests/contract/test_stop_run_endpoint.py @@ -119,7 +119,10 @@ def test_post_stop_run_returns_409_when_aborted() -> None: """Cannot stop an Aborted Run.""" with TestClient(create_app()) as client: run_id = _setup_full_run(client) - client.post(f"/runs/{run_id}/abort", json={"reason": "emergency"}) + client.post( + f"/runs/{run_id}/abort", + json={"reason": "emergency", "justification": "operator: aborting for test"}, + ) response = client.post(f"/runs/{run_id}/stop", json={"reason": "X"}) assert response.status_code == 409 assert "Aborted" in response.json()["detail"] diff --git a/apps/api/tests/contract/test_truncate_run_endpoint.py b/apps/api/tests/contract/test_truncate_run_endpoint.py index 6b36fe285d..b428d54319 100644 --- a/apps/api/tests/contract/test_truncate_run_endpoint.py +++ b/apps/api/tests/contract/test_truncate_run_endpoint.py @@ -142,7 +142,10 @@ def test_post_truncate_run_returns_409_when_aborted() -> None: """Cannot truncate an Aborted Run.""" with TestClient(create_app()) as client: run_id = _setup_full_run(client) - client.post(f"/runs/{run_id}/abort", json={"reason": "emergency"}) + client.post( + f"/runs/{run_id}/abort", + json={"reason": "emergency", "justification": "operator: aborting for test"}, + ) response = client.post(f"/runs/{run_id}/truncate", json={"reason": "X"}) assert response.status_code == 409 assert "Aborted" in response.json()["detail"] diff --git a/apps/api/tests/integration/scenarios/test_2bm_run_debriefer_aborted.py b/apps/api/tests/integration/scenarios/test_2bm_run_debriefer_aborted.py index ac14a88e56..dc605f356b 100644 --- a/apps/api/tests/integration/scenarios/test_2bm_run_debriefer_aborted.py +++ b/apps/api/tests/integration/scenarios/test_2bm_run_debriefer_aborted.py @@ -414,6 +414,7 @@ async def test_run_debrief_agent_fires_on_equipment_abort( "hexapod fault: HexapodAllEnabled stuck at 0; no positioner " "control; aborting scan to run hexapod_reboot procedure" ), + justification="operator: aborting for test", ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, diff --git a/apps/api/tests/integration/test_list_runs_handler_postgres.py b/apps/api/tests/integration/test_list_runs_handler_postgres.py index 71412e7e9e..0d0feb8e7d 100644 --- a/apps/api/tests/integration/test_list_runs_handler_postgres.py +++ b/apps/api/tests/integration/test_list_runs_handler_postgres.py @@ -248,7 +248,11 @@ async def test_every_lifecycle_transition_writes_a_check_constraint_accepted_sta correlation_id=_CORRELATION_ID, ) await bind_abort(deps_4)( - AbortRun(run_id=run_aborted_id, reason="alignment lost"), + AbortRun( + run_id=run_aborted_id, + reason="alignment lost", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) diff --git a/apps/api/tests/integration/test_promote_dataset_handler_postgres.py b/apps/api/tests/integration/test_promote_dataset_handler_postgres.py index 0e9c5eef8f..95fef118f6 100644 --- a/apps/api/tests/integration/test_promote_dataset_handler_postgres.py +++ b/apps/api/tests/integration/test_promote_dataset_handler_postgres.py @@ -208,7 +208,11 @@ async def test_register_dataset_persists_producing_run_end_state_in_payload( ) # Drive the Run to Aborted so the captured end_state is non-trivial. await abort_run.bind(deps)( - AbortRun(run_id=run_id, reason="simulated abort for test"), + AbortRun( + run_id=run_id, + reason="simulated abort for test", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) diff --git a/apps/api/tests/integration/test_run_pause_transitions_handler_postgres.py b/apps/api/tests/integration/test_run_pause_transitions_handler_postgres.py index ad151b69ce..7b853a6120 100644 --- a/apps/api/tests/integration/test_run_pause_transitions_handler_postgres.py +++ b/apps/api/tests/integration/test_run_pause_transitions_handler_postgres.py @@ -331,7 +331,11 @@ async def test_abort_from_held_state_persists_and_round_trips_to_aborted( correlation_id=_CORRELATION_ID, ) await abort_run.bind(deps)( - AbortRun(run_id=run_id, reason="emergency during hold"), + AbortRun( + run_id=run_id, + reason="emergency during hold", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) diff --git a/apps/api/tests/integration/test_run_transitions_handler_postgres.py b/apps/api/tests/integration/test_run_transitions_handler_postgres.py index 59f03a7419..caac596164 100644 --- a/apps/api/tests/integration/test_run_transitions_handler_postgres.py +++ b/apps/api/tests/integration/test_run_transitions_handler_postgres.py @@ -283,7 +283,11 @@ async def test_abort_run_persists_with_trimmed_reason_and_round_trips_to_aborted ) await abort_run.bind(deps)( - AbortRun(run_id=run_id, reason=" detector overheating "), + AbortRun( + run_id=run_id, + reason=" detector overheating ", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) diff --git a/apps/api/tests/unit/infrastructure/test_justification.py b/apps/api/tests/unit/infrastructure/test_justification.py index bc2106692b..54edb17c48 100644 --- a/apps/api/tests/unit/infrastructure/test_justification.py +++ b/apps/api/tests/unit/infrastructure/test_justification.py @@ -1,7 +1,8 @@ """Unit tests for `cora.shared.justification` (the obligation-gate primitive). Coverage: - - Declared set ships EMPTY (the gate is inert until a command opts in). + - Declared set contains AbortRun (the first opt-in); a non-declared command is + unaffected (the gate is inert for it). - Non-declared command: justification is optional; None stays None, text is trimmed, blank-after-trim collapses to None. - Declared command: justification is a fail-closed precondition (None, blank, @@ -21,12 +22,14 @@ require_justification, ) -# ---------- declared set ships empty (dormant primitive) ---------- +# ---------- declared set membership (obligation gate opt-ins) ---------- @pytest.mark.unit -def test_declared_set_ships_empty() -> None: - assert len(COMMANDS_REQUIRING_JUSTIFICATION) == 0 +def test_declared_set_contains_abort_run() -> None: + """AbortRun is the first command to opt into the obligation gate (Gate III): + aborting a running experiment requires an admission justification.""" + assert "AbortRun" in COMMANDS_REQUIRING_JUSTIFICATION # ---------- non-declared command: justification is optional ---------- diff --git a/apps/api/tests/unit/run/test_abort_run_decider.py b/apps/api/tests/unit/run/test_abort_run_decider.py index 291253bd8e..06e026a279 100644 --- a/apps/api/tests/unit/run/test_abort_run_decider.py +++ b/apps/api/tests/unit/run/test_abort_run_decider.py @@ -41,7 +41,11 @@ def test_decide_emits_run_aborted_for_running_state() -> None: state = _run(status=RunStatus.RUNNING) events = abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="detector overheating"), + command=AbortRun( + run_id=state.id, + reason="detector overheating", + justification="operator: aborting for test", + ), now=_NOW, ) assert events == [ @@ -58,7 +62,11 @@ def test_decide_trims_reason_via_value_object() -> None: state = _run() events = abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason=" beam dump unscheduled "), + command=AbortRun( + run_id=state.id, + reason=" beam dump unscheduled ", + justification="operator: aborting for test", + ), now=_NOW, ) assert events[0].reason == "beam dump unscheduled" @@ -70,7 +78,11 @@ def test_decide_raises_run_not_found_when_state_is_none() -> None: with pytest.raises(RunNotFoundError) as exc_info: abort_run.decide( state=None, - command=AbortRun(run_id=target_id, reason="X"), + command=AbortRun( + run_id=target_id, + reason="X", + justification="operator: aborting for test", + ), now=_NOW, ) assert exc_info.value.run_id == target_id @@ -82,7 +94,11 @@ def test_decide_raises_invalid_reason_for_whitespace_only() -> None: with pytest.raises(InvalidRunAbortReasonError): abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason=" "), + command=AbortRun( + run_id=state.id, + reason=" ", + justification="operator: aborting for test", + ), now=_NOW, ) @@ -93,7 +109,11 @@ def test_decide_raises_invalid_reason_for_too_long() -> None: with pytest.raises(InvalidRunAbortReasonError): abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="a" * 501), + command=AbortRun( + run_id=state.id, + reason="a" * 501, + justification="operator: aborting for test", + ), now=_NOW, ) @@ -104,7 +124,11 @@ def test_decide_raises_cannot_abort_when_already_completed() -> None: with pytest.raises(RunCannotAbortError) as exc_info: abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="X"), + command=AbortRun( + run_id=state.id, + reason="X", + justification="operator: aborting for test", + ), now=_NOW, ) assert exc_info.value.run_id == state.id @@ -118,7 +142,11 @@ def test_decide_raises_cannot_abort_when_already_aborted() -> None: with pytest.raises(RunCannotAbortError) as exc_info: abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="X"), + command=AbortRun( + run_id=state.id, + reason="X", + justification="operator: aborting for test", + ), now=_NOW, ) assert exc_info.value.current_status is RunStatus.ABORTED @@ -131,7 +159,11 @@ def test_decide_raises_cannot_abort_when_already_stopped() -> None: with pytest.raises(RunCannotAbortError) as exc_info: abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="X"), + command=AbortRun( + run_id=state.id, + reason="X", + justification="operator: aborting for test", + ), now=_NOW, ) assert exc_info.value.current_status is RunStatus.STOPPED @@ -144,7 +176,11 @@ def test_decide_error_message_names_required_running_or_held_status() -> None: with pytest.raises(RunCannotAbortError) as exc_info: abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="X"), + command=AbortRun( + run_id=state.id, + reason="X", + justification="operator: aborting for test", + ), now=_NOW, ) msg = str(exc_info.value) @@ -161,7 +197,11 @@ def test_decide_accepts_held_source_state_in_6f3() -> None: state = _run(status=RunStatus.HELD) events = abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="emergency during hold"), + command=AbortRun( + run_id=state.id, + reason="emergency during hold", + justification="operator: aborting for test", + ), now=_NOW, ) assert len(events) == 1 @@ -171,7 +211,11 @@ def test_decide_accepts_held_source_state_in_6f3() -> None: @pytest.mark.unit def test_decide_is_pure_same_inputs_same_outputs() -> None: state = _run() - command = AbortRun(run_id=state.id, reason="X") + command = AbortRun( + run_id=state.id, + reason="X", + justification="operator: aborting for test", + ) first = abort_run.decide(state=state, command=command, now=_NOW) second = abort_run.decide(state=state, command=command, now=_NOW) assert first == second @@ -186,7 +230,11 @@ def test_decide_defaults_decided_by_decision_id_to_none_when_omitted() -> None: state = _run(status=RunStatus.RUNNING) events = abort_run.decide( state=state, - command=AbortRun(run_id=state.id, reason="detector overheating"), + command=AbortRun( + run_id=state.id, + reason="detector overheating", + justification="operator: aborting for test", + ), now=_NOW, ) assert events[0].decided_by_decision_id is None @@ -203,6 +251,7 @@ def test_decide_threads_decided_by_decision_id_through_to_event() -> None: run_id=state.id, reason="agent EquipmentAbortDecision triggered", decided_by_decision_id=decision_id, + justification="operator: aborting for test", ), now=_NOW, ) diff --git a/apps/api/tests/unit/run/test_abort_run_decider_properties.py b/apps/api/tests/unit/run/test_abort_run_decider_properties.py index 6e4d02b599..79c469d67f 100644 --- a/apps/api/tests/unit/run/test_abort_run_decider_properties.py +++ b/apps/api/tests/unit/run/test_abort_run_decider_properties.py @@ -69,7 +69,15 @@ def test_abort_with_none_state_always_raises_not_found( ) -> None: """Empty stream always raises `RunNotFoundError` carrying command.run_id.""" with pytest.raises(RunNotFoundError) as exc: - abort_run.decide(state=None, command=AbortRun(run_id=run_id, reason=reason), now=now) + abort_run.decide( + state=None, + command=AbortRun( + run_id=run_id, + reason=reason, + justification="operator: aborting for test", + ), + now=now, + ) assert exc.value.run_id == run_id @@ -91,7 +99,12 @@ def test_abort_from_permitted_source_emits_single_event( """Running and Held both emit one RunAborted with the threaded reason.""" events = abort_run.decide( state=_run(run_id=run_id, status=source), - command=AbortRun(run_id=run_id, reason=reason, decided_by_decision_id=decision_id), + command=AbortRun( + run_id=run_id, + reason=reason, + decided_by_decision_id=decision_id, + justification="operator: aborting for test", + ), now=now, ) assert events == [ @@ -125,7 +138,11 @@ def test_abort_from_terminal_source_always_raises_cannot_abort( with pytest.raises(RunCannotAbortError) as exc: abort_run.decide( state=_run(run_id=run_id, status=source), - command=AbortRun(run_id=run_id, reason=reason), + command=AbortRun( + run_id=run_id, + reason=reason, + justification="operator: aborting for test", + ), now=now, ) assert exc.value.current_status is source @@ -150,7 +167,11 @@ def test_abort_uses_state_id_not_command_run_id( assume(state_run_id != command_run_id) events = abort_run.decide( state=_run(run_id=state_run_id, status=source), - command=AbortRun(run_id=command_run_id, reason=reason), + command=AbortRun( + run_id=command_run_id, + reason=reason, + justification="operator: aborting for test", + ), now=now, ) assert events[0].run_id == state_run_id @@ -165,7 +186,11 @@ def test_abort_is_pure_same_input_same_output( ) -> None: """Two calls with identical args return equal events (no clock leakage).""" state = _run(run_id=run_id, status=RunStatus.RUNNING) - command = AbortRun(run_id=run_id, reason=reason) + command = AbortRun( + run_id=run_id, + reason=reason, + justification="operator: aborting for test", + ) first = abort_run.decide(state=state, command=command, now=now) second = abort_run.decide(state=state, command=command, now=now) assert first == second diff --git a/apps/api/tests/unit/run/test_abort_run_handler.py b/apps/api/tests/unit/run/test_abort_run_handler.py index 9ff0d3f2d9..bb79b5a132 100644 --- a/apps/api/tests/unit/run/test_abort_run_handler.py +++ b/apps/api/tests/unit/run/test_abort_run_handler.py @@ -77,7 +77,11 @@ async def test_handler_returns_none_on_success() -> None: deps = build_deps(ids=[_ABORTED_EVENT_ID], now=_NOW, event_store=store) result = await abort_run.bind(deps)( - AbortRun(run_id=_RUN_ID, reason="detector overheating"), + AbortRun( + run_id=_RUN_ID, + reason="detector overheating", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) @@ -91,7 +95,11 @@ async def test_handler_appends_run_aborted_event_with_trimmed_reason() -> None: deps = build_deps(ids=[_ABORTED_EVENT_ID], now=_NOW, event_store=store) await abort_run.bind(deps)( - AbortRun(run_id=_RUN_ID, reason=" beam dump unscheduled "), + AbortRun( + run_id=_RUN_ID, + reason=" beam dump unscheduled ", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) @@ -112,7 +120,11 @@ async def test_handler_raises_run_not_found_when_run_does_not_exist() -> None: with pytest.raises(RunNotFoundError): await handler( - AbortRun(run_id=_RUN_ID, reason="X"), + AbortRun( + run_id=_RUN_ID, + reason="X", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) @@ -126,7 +138,11 @@ async def test_handler_raises_invalid_reason_for_whitespace_only() -> None: with pytest.raises(InvalidRunAbortReasonError): await abort_run.bind(deps)( - AbortRun(run_id=_RUN_ID, reason=" "), + AbortRun( + run_id=_RUN_ID, + reason=" ", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) @@ -141,7 +157,11 @@ async def test_handler_raises_cannot_abort_when_already_aborted() -> None: with pytest.raises(RunCannotAbortError): await abort_run.bind(deps)( - AbortRun(run_id=_RUN_ID, reason="X"), + AbortRun( + run_id=_RUN_ID, + reason="X", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) @@ -155,7 +175,11 @@ async def test_handler_raises_unauthorized_on_deny() -> None: with pytest.raises(UnauthorizedError) as exc_info: await abort_run.bind(deny_deps)( - AbortRun(run_id=_RUN_ID, reason="X"), + AbortRun( + run_id=_RUN_ID, + reason="X", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, ) @@ -170,7 +194,11 @@ async def test_handler_propagates_causation_id_to_appended_event() -> None: deps = build_deps(ids=[_ABORTED_EVENT_ID], now=_NOW, event_store=store) await abort_run.bind(deps)( - AbortRun(run_id=_RUN_ID, reason="X"), + AbortRun( + run_id=_RUN_ID, + reason="X", + justification="operator: aborting for test", + ), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID, causation_id=causation, diff --git a/apps/api/tests/unit/run/test_abort_run_obligation_gate.py b/apps/api/tests/unit/run/test_abort_run_obligation_gate.py new file mode 100644 index 0000000000..b1495c4f0e --- /dev/null +++ b/apps/api/tests/unit/run/test_abort_run_obligation_gate.py @@ -0,0 +1,135 @@ +"""Unit tests for the obligation gate (Gate III) on the abort_run decider. + +AbortRun is in COMMANDS_REQUIRING_JUSTIFICATION, so the decider requires a +non-empty, bounded justification at admission (fail-closed) BEFORE any state / +reason / status check. The gate is total, outermost, and kind-blind: it reads only +the command name + justification text, never actor kind, so a human and an agent +are held to the identical precondition. +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.run.aggregates.run import Run, RunName, RunStatus +from cora.run.features.abort_run import decide +from cora.run.features.abort_run.command import AbortRun +from cora.shared.justification import ( + COMMANDS_REQUIRING_JUSTIFICATION, + JUSTIFICATION_MAX_LENGTH, + JustificationRequiredError, +) + +_NOW = datetime(2026, 7, 7, 12, 0, 0, tzinfo=UTC) + + +def _running_run(run_id: UUID) -> Run: + return Run( + id=run_id, + name=RunName("test run"), + plan_id=uuid4(), + subject_id=None, + status=RunStatus.RUNNING, + ) + + +@pytest.mark.unit +def test_abort_run_is_in_the_declared_class() -> None: + """The decider gates on the command name "AbortRun"; the allowlist must contain + it or the gate silently goes inert. Pins the two-place coupling (the decider's + literal and the allowlist membership live in separate files). The behavioral + tests below (refuse-when-absent) are the other half of the guard: they fail if + the decider ever stops calling require_justification.""" + assert "AbortRun" in COMMANDS_REQUIRING_JUSTIFICATION + + +@pytest.mark.unit +def test_abort_without_justification_is_refused() -> None: + run_id = uuid4() + with pytest.raises(JustificationRequiredError) as exc: + decide( + _running_run(run_id), + AbortRun(run_id=run_id, reason="beam dump"), + now=_NOW, + ) + assert exc.value.command_name == "AbortRun" + + +@pytest.mark.unit +def test_abort_with_justification_is_admitted() -> None: + run_id = uuid4() + events = decide( + _running_run(run_id), + AbortRun(run_id=run_id, reason="beam dump", justification="detector arc, safety abort"), + now=_NOW, + ) + assert len(events) == 1 + assert events[0].run_id == run_id + # The post-hoc reason still lands on the event; justification is admission-only. + assert events[0].reason == "beam dump" + + +@pytest.mark.unit +@pytest.mark.parametrize("blank", ["", " ", "\t\n"]) +def test_blank_justification_is_refused(blank: str) -> None: + run_id = uuid4() + with pytest.raises(JustificationRequiredError): + decide( + _running_run(run_id), + AbortRun(run_id=run_id, reason="x", justification=blank), + now=_NOW, + ) + + +@pytest.mark.unit +def test_over_length_justification_is_refused() -> None: + run_id = uuid4() + with pytest.raises(JustificationRequiredError): + decide( + _running_run(run_id), + AbortRun( + run_id=run_id, + reason="x", + justification="j" * (JUSTIFICATION_MAX_LENGTH + 1), + ), + now=_NOW, + ) + + +@pytest.mark.unit +def test_gate_precedes_state_check() -> None: + """The obligation gate is the OUTER precondition: an unjustified abort on a + missing run raises JustificationRequiredError (the gate), NOT RunNotFoundError. + Admission is decided before existence.""" + with pytest.raises(JustificationRequiredError): + decide(None, AbortRun(run_id=uuid4(), reason="x"), now=_NOW) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "status", + [RunStatus.RUNNING, RunStatus.HELD, RunStatus.COMPLETED, RunStatus.ABORTED, RunStatus.STOPPED], +) +def test_unjustified_abort_always_refuses_regardless_of_state(status: RunStatus) -> None: + """Total + outermost: for ANY run status, an unjustified abort raises the gate + error before the reason-validation / status-transition checks can raise their + own (e.g. RunCannotAbortError on a terminal run).""" + run_id = uuid4() + state = Run(id=run_id, name=RunName("r"), plan_id=uuid4(), subject_id=None, status=status) + with pytest.raises(JustificationRequiredError): + decide(state, AbortRun(run_id=run_id, reason="x"), now=_NOW) + + +@pytest.mark.unit +def test_gate_is_kind_blind() -> None: + """The gate reads only command name + text, never actor kind: the decider has + no actor-kind argument, so a human and an agent abort under the identical + precondition. This test documents the invariant by exercising the same decide() + with a justification (admitted) and without (refused): the outcome depends + only on the justification, never on who is aborting.""" + run_id = uuid4() + justified = AbortRun(run_id=run_id, reason="x", justification="accounted for") + assert decide(_running_run(run_id), justified, now=_NOW) # admitted for anyone + with pytest.raises(JustificationRequiredError): + decide(_running_run(run_id), AbortRun(run_id=run_id, reason="x"), now=_NOW)