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
63 changes: 63 additions & 0 deletions docs/pre-dispatch-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# AgentTerm Pre-Dispatch Boundary

## Purpose

`AgentTermPreDispatchDecision` is the decision-only boundary between an operator or interaction event and any later runtime dispatch.

AgentTerm is the terminal-native / Matrix-first operator console. It is not the authority plane for non-human identity, grants, policy admission, sensitive context release, or side-effecting execution.

This object exists so later participant adapters for Hermes, Codex, Claude Code, OpenCLAW, GitHub, CI, MCP, terminal, Matrix, and AgentPlane cannot dispatch from local config alone.

## Required chain

```text
operator / interaction event = evidence input
Agent Registry lookup = identity / session / grant / revocation evidence
Policy Fabric decision = action/context policy evaluation
AgentTerm pre-dispatch decision = local runtime readiness decision
AgentPlane / terminal / Matrix adapter = downstream execution surface
OpsHistory / SourceOSInteractionEvent = record/render path only
```

## Boundary rules

A pre-dispatch decision is not execution.

It must record:

- participant ref and kind;
- Agent Registry ref for non-human participants;
- grant refs and session ref for non-human participants;
- revocation state;
- Policy Fabric decision refs for side-effecting actions;
- policy status;
- dispatch decision;
- target adapter;
- side-effect posture;
- sensitive-context posture;
- evidence refs;
- `performed_dispatch = false`.

## Fail-closed cases

The validator rejects:

- non-human participant dispatch from local config alone;
- non-human participant without grants or session ref;
- revoked or unknown revocation state allowing dispatch;
- side-effecting action without Policy Fabric decision refs;
- sensitive context without policy admission;
- pre-dispatch record claiming dispatch already occurred.

## Related issues

- #8 registered non-human participants and pre-dispatch Agent Registry / Policy Fabric checks.
- #18 SourceOS Agent Machine workspace integration and governed execution handoff.
- #43 SourceOSInteractionEvent governance trace rendering.
- #44 pre-dispatch boundary issue.

## Non-goals

This tranche does not add live provider calls, terminal execution, Matrix sends, MCP execution, AgentPlane calls, or Agent Registry network access.

It only defines and validates the decision object that must exist before those surfaces are wired.
185 changes: 185 additions & 0 deletions src/agent_term/pre_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Pre-dispatch decision boundary for AgentTerm runtime actions.

AgentTerm renders, records, and coordinates operator events. It is not the
authority for non-human participant identity, grants, policy admission, or
side-effecting execution. This module creates a typed decision object that must
exist before dispatch is performed by an adapter.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, Literal

DispatchDecision = Literal["allow", "require-review", "deny", "fail-closed"]
RevocationState = Literal["not-revoked", "revoked", "unknown"]

SIDE_EFFECTING_ACTIONS = frozenset(
{
"shell_session",
"shell_attach",
"terminal_attach",
"tool_use",
"mcp_tool_call",
"github_mutation",
"ci_retry",
"workspace_materialization",
"memory_write",
"matrix_service_send",
}
)


@dataclass(frozen=True)
class AgentTermPreDispatchDecision:
"""Decision-only record produced before any runtime dispatch occurs."""

decision_id: str
requested_action: str
participant_ref: str
participant_kind: str
agent_registry_ref: str | None
grant_refs: tuple[str, ...]
session_ref: str | None
revocation_state: RevocationState
policy_decision_refs: tuple[str, ...]
policy_status: str
dispatch_decision: DispatchDecision
dispatch_target: str
side_effecting: bool
sensitive_context_requested: bool = False
context_pack_refs: tuple[str, ...] = ()
evidence_refs: tuple[str, ...] = ()
performed_dispatch: bool = False
reason: str | None = None
decided_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())

def to_dict(self) -> dict[str, Any]:
return {
"schemaVersion": "agent-term.pre-dispatch-decision.v0.1",
"recordType": "AgentTermPreDispatchDecision",
"decision_id": self.decision_id,
"requested_action": self.requested_action,
"participant_ref": self.participant_ref,
"participant_kind": self.participant_kind,
"agent_registry_ref": self.agent_registry_ref,
"grant_refs": list(self.grant_refs),
"session_ref": self.session_ref,
"revocation_state": self.revocation_state,
"policy_decision_refs": list(self.policy_decision_refs),
"policy_status": self.policy_status,
"dispatch_decision": self.dispatch_decision,
"dispatch_target": self.dispatch_target,
"side_effecting": self.side_effecting,
"sensitive_context_requested": self.sensitive_context_requested,
"context_pack_refs": list(self.context_pack_refs),
"evidence_refs": list(self.evidence_refs),
"performed_dispatch": self.performed_dispatch,
"reason": self.reason,
"decided_at": self.decided_at,
}


def build_pre_dispatch_decision(
*,
decision_id: str,
requested_action: str,
participant_ref: str,
participant_kind: str,
dispatch_target: str,
agent_registry_ref: str | None = None,
grant_refs: tuple[str, ...] = (),
session_ref: str | None = None,
revocation_state: RevocationState = "unknown",
policy_decision_refs: tuple[str, ...] = (),
policy_status: str = "unknown",
sensitive_context_requested: bool = False,
context_pack_refs: tuple[str, ...] = (),
evidence_refs: tuple[str, ...] = (),
) -> AgentTermPreDispatchDecision:
"""Build a decision-only pre-dispatch record.

This function never performs dispatch. It classifies whether a later adapter
may dispatch after Agent Registry and Policy Fabric evidence has been checked.
"""

side_effecting = requested_action in SIDE_EFFECTING_ACTIONS
decision: DispatchDecision = "allow"
reason: str | None = "all required pre-dispatch gates satisfied"

if participant_kind != "human" and not agent_registry_ref:
decision = "fail-closed"
reason = "non-human participant missing Agent Registry resolution"
elif participant_kind != "human" and (not grant_refs or not session_ref):
decision = "fail-closed"
reason = "non-human participant missing grant or session reference"
elif revocation_state != "not-revoked":
decision = "deny" if revocation_state == "revoked" else "fail-closed"
reason = f"participant revocation_state={revocation_state}"
elif side_effecting and not policy_decision_refs:
decision = "fail-closed"
reason = "side-effecting action missing Policy Fabric decision refs"
elif sensitive_context_requested and policy_status != "allow":
decision = "deny" if policy_status in {"deny", "denied"} else "require-review"
reason = "sensitive context requires explicit policy admission"
elif policy_status in {"deny", "denied"}:
decision = "deny"
reason = "Policy Fabric denied dispatch"
elif policy_status in {"pending", "require-review"}:
decision = "require-review"
reason = "Policy Fabric requires review before dispatch"

record = AgentTermPreDispatchDecision(
decision_id=decision_id,
requested_action=requested_action,
participant_ref=participant_ref,
participant_kind=participant_kind,
agent_registry_ref=agent_registry_ref,
grant_refs=grant_refs,
session_ref=session_ref,
revocation_state=revocation_state,
policy_decision_refs=policy_decision_refs,
policy_status=policy_status,
dispatch_decision=decision,
dispatch_target=dispatch_target,
side_effecting=side_effecting,
sensitive_context_requested=sensitive_context_requested,
context_pack_refs=context_pack_refs,
evidence_refs=evidence_refs,
performed_dispatch=False,
reason=reason,
)
validate_pre_dispatch_decision(record)
return record


def validate_pre_dispatch_decision(record: AgentTermPreDispatchDecision | dict[str, Any]) -> None:
"""Reject collapsed pre-dispatch records."""

data = record.to_dict() if isinstance(record, AgentTermPreDispatchDecision) else record
if data.get("recordType") != "AgentTermPreDispatchDecision":
raise ValueError("recordType must be AgentTermPreDispatchDecision")
if data.get("performed_dispatch") is not False:
raise ValueError("pre-dispatch decisions must not claim dispatch was performed")

participant_kind = str(data.get("participant_kind", ""))
if participant_kind != "human":
if not data.get("agent_registry_ref"):
raise ValueError("non-human participant requires Agent Registry ref")
if not data.get("grant_refs"):
raise ValueError("non-human participant requires grant refs")
if not data.get("session_ref"):
raise ValueError("non-human participant requires session ref")

if data.get("revocation_state") in {"revoked", "unknown"} and data.get("dispatch_decision") == "allow":
raise ValueError("revoked or unknown revocation state cannot allow dispatch")

if data.get("side_effecting") is True and not data.get("policy_decision_refs"):
raise ValueError("side-effecting dispatch requires policy decision refs")

if data.get("sensitive_context_requested") is True and data.get("policy_status") not in {"allow", "admitted"}:
raise ValueError("sensitive context requires policy admission")

if data.get("dispatch_decision") == "allow" and data.get("policy_status") in {"deny", "denied", "pending", "require-review"}:
raise ValueError("allow dispatch is inconsistent with policy status")
107 changes: 107 additions & 0 deletions tests/test_pre_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from __future__ import annotations

import pytest

from agent_term.pre_dispatch import build_pre_dispatch_decision, validate_pre_dispatch_decision


def test_pre_dispatch_allows_resolved_non_human_with_policy_refs():
decision = build_pre_dispatch_decision(
decision_id="pre-dispatch:ok-001",
requested_action="tool_use",
participant_ref="agent://codex",
participant_kind="agent",
agent_registry_ref="agent-registry://codex",
grant_refs=("grant://codex/tool-use",),
session_ref="session://codex/001",
revocation_state="not-revoked",
policy_decision_refs=("policy-decision://tool-use/allow",),
policy_status="allow",
dispatch_target="adapter://codex",
evidence_refs=("evidence://agent-term/pre-dispatch/ok-001",),
)

assert decision.dispatch_decision == "allow"
assert decision.performed_dispatch is False
assert decision.side_effecting is True
validate_pre_dispatch_decision(decision)


def test_pre_dispatch_rejects_local_config_only_non_human():
with pytest.raises(ValueError, match="Agent Registry ref"):
build_pre_dispatch_decision(
decision_id="pre-dispatch:local-only-invalid",
requested_action="tool_use",
participant_ref="agent://codex",
participant_kind="agent",
dispatch_target="adapter://codex",
policy_decision_refs=("policy-decision://tool-use/allow",),
policy_status="allow",
)


def test_pre_dispatch_rejects_revoked_grant_dispatch():
decision = build_pre_dispatch_decision(
decision_id="pre-dispatch:revoked-001",
requested_action="tool_use",
participant_ref="agent://codex",
participant_kind="agent",
agent_registry_ref="agent-registry://codex",
grant_refs=("grant://codex/tool-use",),
session_ref="session://codex/001",
revocation_state="revoked",
policy_decision_refs=("policy-decision://tool-use/allow",),
policy_status="allow",
dispatch_target="adapter://codex",
)

assert decision.dispatch_decision == "deny"
with pytest.raises(ValueError, match="revoked or unknown"):
mutated = decision.to_dict()
mutated["dispatch_decision"] = "allow"
validate_pre_dispatch_decision(mutated)


def test_pre_dispatch_rejects_side_effect_without_policy_refs():
with pytest.raises(ValueError, match="policy decision refs"):
build_pre_dispatch_decision(
decision_id="pre-dispatch:no-policy-invalid",
requested_action="terminal_attach",
participant_ref="human://operator",
participant_kind="human",
revocation_state="not-revoked",
policy_status="allow",
dispatch_target="adapter://terminal",
)


def test_pre_dispatch_rejects_sensitive_context_without_policy_admission():
with pytest.raises(ValueError, match="sensitive context"):
build_pre_dispatch_decision(
decision_id="pre-dispatch:sensitive-invalid",
requested_action="context_pack",
participant_ref="human://operator",
participant_kind="human",
revocation_state="not-revoked",
policy_decision_refs=("policy-decision://context/pending",),
policy_status="pending",
sensitive_context_requested=True,
context_pack_refs=("context-pack://secret",),
dispatch_target="adapter://context",
)


def test_pre_dispatch_rejects_record_that_claims_dispatch_performed():
decision = build_pre_dispatch_decision(
decision_id="pre-dispatch:mutated-invalid",
requested_action="read",
participant_ref="human://operator",
participant_kind="human",
revocation_state="not-revoked",
policy_status="allow",
dispatch_target="adapter://noop",
)
payload = decision.to_dict()
payload["performed_dispatch"] = True
with pytest.raises(ValueError, match="must not claim dispatch"):
validate_pre_dispatch_decision(payload)
Loading