diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7610e330eaa..4c486ee8b3c 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 930db645018..210640d9d92 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 5d3fd4d169f..96366bdb06b 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 f542a30decf..d6e957cb50a 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 d44d0f374df..45ecd69be97 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 97d927ec69d..25edf1349e2 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 1565d02891d..e02119110cf 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 a7b3f8ab682..e8947a5b801 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 4a232e66b1e..96cccd20c46 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 new file mode 100644 index 00000000000..8006ad17317 --- /dev/null +++ b/apps/api/src/cora/shared/justification.py @@ -0,0 +1,130 @@ +"""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. 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 +"""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({"AbortRun"}) +"""The declared class: command names that require a justification at admission. + +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. +""" + + +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/contract/test_abort_run_endpoint.py b/apps/api/tests/contract/test_abort_run_endpoint.py index 22ec640adb8..1ec3424d028 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 af13f6bc5fd..8b85257c50f 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 442a92d1357..cb8f588dea0 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 60aecbe10e9..0420a53955c 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 8002aaff35a..14487209fc6 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 44966bd328c..4ee9983b80e 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 d1a908d6873..7b1ec76e22e 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 3bbafdb41a6..329dd0a18be 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 6b36fe285d3..b428d543196 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 ac14a88e56c..dc605f356bf 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 71412e7e9e0..0d0feb8e7d3 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 0e9c5eef8f0..95fef118f6e 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 ad151b69ce7..7b853a6120f 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 59f03a74198..caac5961643 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 new file mode 100644 index 00000000000..54edb17c482 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_justification.py @@ -0,0 +1,134 @@ +"""Unit tests for `cora.shared.justification` (the obligation-gate primitive). + +Coverage: + - 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, + 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 membership (obligation gate opt-ins) ---------- + + +@pytest.mark.unit +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 ---------- + + +@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" 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 291253bd8ef..06e026a279b 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 6e4d02b599a..79c469d67ff 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 9ff0d3f2d96..bb79b5a132b 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 00000000000..b1495c4f0e7 --- /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)