Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/cora/api/_edge_conductor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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
),
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/cora/api/_run_phase_conduct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/cora/api/_run_supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/cora/run/features/abort_run/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,20 @@ 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
reason: str
decided_by_decision_id: UUID | None = None
actuation_kind: str | None = None
producing_job_id: str | None = None
justification: str | None = None
17 changes: 17 additions & 0 deletions apps/api/src/cora/run/features/abort_run/decider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down
21 changes: 20 additions & 1 deletion apps/api/src/cora/run/features/abort_run/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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=(
Expand Down Expand Up @@ -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)",
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/cora/run/features/abort_run/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/cora/run/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
stop_run,
truncate_run,
)
from cora.shared.justification import JustificationRequiredError


async def _handle_validation_error(request: Request, exc: Exception) -> JSONResponse:
Expand All @@ -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)
Expand Down Expand Up @@ -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,):
Expand Down
Loading
Loading