diff --git a/standards/agent-continuity/README.md b/standards/agent-continuity/README.md index 5815f23..3a9b3fa 100644 --- a/standards/agent-continuity/README.md +++ b/standards/agent-continuity/README.md @@ -32,6 +32,20 @@ Core boundaries: The executable reference uses a two-phase handoff: a target can become `HANDOFF_DELIVERABLE` only after current surfaced reachability is checked, and ownership may advance only after the target acknowledges the exact handoff occurrence and ownership epoch. +## ORC-001 — Orphan Cascade Revocation + +[`ORC-001 — Orphan Cascade Revocation`](./handoff-reachability/ORC-001-ORPHAN-CASCADE-REVOCATION.md) +extends HRC ownership epochs into already-running parent/child execution trees. + +Core boundaries: + +```text +RUNNING != AUTHORIZED +KILL_REQUESTED != EXITED +``` + +When ownership advances, stale roots and descendants lose side-effect authority immediately through their inherited owner/epoch binding. Process-group cancellation remains a separate cleanup control, and quiescence is only reported after stale executions are observed non-running. + ## Problem Coding agents may lose task continuity after compaction or handoff. They can repeat completed work, violate recent constraints, forget rejected approaches, or confidently reconstruct an execution history that is not supported by durable evidence. @@ -47,7 +61,7 @@ Coding agents may lose task continuity after compaction or handoff. They can rep - example envelope and restore results; - reference validator; - executable conformance tests; -- additive causal coordination contracts such as HRC-001. +- additive causal coordination contracts such as HRC-001 and ORC-001. ## Quick validation @@ -59,7 +73,7 @@ python -m unittest discover \ -p 'test_*.py' -v ``` -HRC-001 can be run independently: +HRC-001 and ORC-001 can be run independently: ```bash python -m pip install -r standards/agent-continuity/handoff-reachability/conformance/requirements.txt @@ -81,7 +95,10 @@ python -m unittest discover \ - ownership-epoch gating; - unread-predecessor versus CAS-conflict separation; - surfaced and time-bounded reachability; -- exact recipient acknowledgement before handoff commit. +- exact recipient acknowledgement before handoff commit; +- stale-root and stale-descendant side-effect revocation; +- inherited execution-authority lineage; +- kill-request versus observed-quiescence separation. ## Intended integrations diff --git a/standards/agent-continuity/handoff-reachability/ORC-001-ORPHAN-CASCADE-REVOCATION.md b/standards/agent-continuity/handoff-reachability/ORC-001-ORPHAN-CASCADE-REVOCATION.md new file mode 100644 index 0000000..de9c55d --- /dev/null +++ b/standards/agent-continuity/handoff-reachability/ORC-001-ORPHAN-CASCADE-REVOCATION.md @@ -0,0 +1,225 @@ +# ORC-001 — Orphan Cascade Revocation + +**Status:** Draft v0.1 +**Scope:** framework-neutral execution-authority revocation for parent/child agent trees +**Relationship:** extends HRC-001 ownership epochs into already-running execution trees; does not define process supervision or transport. + +## 1. Problem + +A multi-agent runtime can correctly transfer task ownership and still leave old execution processes alive. + +Example: + +```text +owner A @ epoch 4 + └─ execution A/root + └─ child C + └─ grandchild D + +handoff commits to owner B @ epoch 5 + +A/C/D may still be RUNNING +``` + +Killing the old process group is useful cleanup, but it is not a sufficient authorization boundary. A delayed child may survive briefly, a cancellation signal may be dropped, or an orphan may continue after its parent exits. + +The safety requirement is therefore stronger: + +> When authority advances, every descendant bound to the superseded authority generation loses permission to create consequential side effects immediately, whether or not the operating-system process has terminated yet. + +ORC-001 makes that boundary explicit. + +## 2. Core separation + +ORC-001 keeps three facts distinct: + +```text +authority validity +process liveness +quiescence +``` + +They are not interchangeable: + +```text +RUNNING != AUTHORIZED +KILL_REQUESTED != EXITED +EXITED != PROOF_NO_PRIOR_SIDE_EFFECT +``` + +HRC-001 establishes the current lane owner and ownership epoch. ORC-001 binds each execution tree to that authority generation and evaluates whether later side effects are still admissible. + +## 3. Execution authority binding + +Every consequential execution SHOULD carry a bounded authority binding: + +```text +execution_id +authority_lane_id +authority_owner_ref +authority_epoch +root_execution_id +parent_execution_id (optional for root) +actor_ref +process_group_ref (optional) +``` + +A child MUST inherit the same: + +```text +authority_lane_id +authority_owner_ref +authority_epoch +root_execution_id +``` + +from its parent. + +A child cannot refresh or mint a newer authority epoch merely because the current lane state has advanced. New authority requires a new execution lineage admitted under the new owner/epoch. + +## 4. Side-effect admission + +A side-effect attempt is admissible only when the execution's authority binding still matches the current HRC lane state: + +```text +execution.authority_lane_id == state.lane_id +execution.authority_owner_ref == state.owner_ref +execution.authority_epoch == state.ownership_epoch +``` + +When a parent execution is supplied, the evaluator also requires: + +```text +child.parent_execution_id == parent.execution_id +child.root_execution_id == parent.root_execution_id +child.authority_lane_id == parent.authority_lane_id +child.authority_owner_ref == parent.authority_owner_ref +child.authority_epoch == parent.authority_epoch +``` + +This prevents a descendant of revoked owner A/epoch 4 from silently rebinding itself to owner B/epoch 5. + +## 5. Cascade revocation + +Suppose HRC-001 commits: + +```text +owner A / epoch 4 + ↓ +owner B / epoch 5 +``` + +Every execution whose authority binding is still `(A, 4)` becomes revoked for consequential side effects, including all descendants. + +The revocation does not require the runtime to discover or kill every process before enforcing the admission boundary. Process cleanup may happen concurrently. + +Reference result for a superseded epoch: + +```text +BLOCKED_REVOKED_AUTHORITY_EPOCH +``` + +Reference result for a superseded owner under an otherwise matching epoch: + +```text +BLOCKED_REVOKED_AUTHORITY_OWNER +``` + +## 6. Process supervision is a separate control + +A runtime MAY use process groups, job objects, cgroups, supervisors, containers or another mechanism to terminate obsolete descendants. + +ORC-001 treats these as liveness/quiescence controls, not as proof of authority. + +A stale execution that remains `RUNNING` after authority advances yields: + +```text +REVOCATION_PENDING_LIVE_EXECUTIONS +``` + +Only when all executions bound to superseded authority are observed non-running may the reference report: + +```text +REVOCATION_QUIESCENT +``` + +A cancellation request alone is insufficient to claim quiescence. + +## 7. Conformance invariants + +### ORC-I1 — Authority revocation is immediate + +> After the current ownership epoch advances, an execution bound to the previous epoch MUST NOT be admitted for a consequential side effect, even if its process is still alive. + +### ORC-I2 — Revocation cascades by inherited binding + +> Descendants inherit the parent's authority owner, epoch, lane and root execution identity. Every descendant of a superseded authority generation is therefore revoked without requiring per-child ownership mutation. + +### ORC-I3 — Descendants cannot self-refresh authority + +> A child MUST NOT change its authority owner or epoch relative to its parent merely to match newer lane state. + +### ORC-I4 — Process liveness is not authority + +> A running process does not imply current execution permission. + +### ORC-I5 — Kill requested is not quiescent + +> A cancellation or kill request MUST NOT be reported as process termination without an observed non-running state. + +### ORC-I6 — Quiescence and admission are independent + +> The runtime SHOULD block revoked side effects immediately and MAY complete process cleanup later. Safety MUST NOT depend on cleanup winning a race against the next side effect. + +### ORC-I7 — New owner requires new lineage + +> Work under the new owner/epoch MUST begin in an execution lineage explicitly bound to that new authority; a revoked descendant cannot become valid by mutating its binding. + +### ORC-I8 — Revocation does not erase evidence + +> Revoking future side effects does not prove that the stale execution produced no earlier side effects. Existing execution/evidence receipts remain independently auditable. + +## 8. Reference statuses + +The reference evaluator emits bounded statuses including: + +```text +SIDE_EFFECT_ALLOWED +BLOCKED_LANE_MISMATCH +BLOCKED_PARENT_EXECUTION_MISMATCH +BLOCKED_AUTHORITY_LINEAGE_ESCAPE +BLOCKED_REVOKED_AUTHORITY_EPOCH +BLOCKED_REVOKED_AUTHORITY_OWNER +REVOCATION_PENDING_LIVE_EXECUTIONS +REVOCATION_QUIESCENT +``` + +## 9. Minimum falsification scenarios + +A conforming implementation should demonstrate at least: + +1. current owner/current epoch root execution may perform a side effect; +2. current child with inherited binding may perform a side effect; +3. handoff advances epoch and immediately blocks the old root; +4. the same handoff immediately blocks a still-running child; +5. the same handoff immediately blocks a still-running grandchild; +6. a child cannot rewrite its binding to the new epoch while remaining in the old lineage; +7. a kill-requested but still-running stale descendant prevents quiescence; +8. observed exit of all stale descendants allows quiescence; +9. new owner/new epoch in a new root lineage is admissible; +10. revocation status does not claim absence of prior external effects. + +## 10. Non-claims + +ORC-001 does **not** claim: + +- that process groups are the only valid cleanup primitive; +- that a successful kill request proves all descendants exited; +- that blocked future admission rolls back an external side effect already committed; +- that all tools currently expose an authority-admission hook; +- that Claude Code or another vendor has adopted this contract; +- that the motivating public discussion proves this failure class occurs at a particular frequency. + +## 11. Governing principle + +> Revoke authority before relying on cleanup: a stale process may remain alive, but it must not remain authorized. diff --git a/standards/agent-continuity/handoff-reachability/conformance/orc_reference.py b/standards/agent-continuity/handoff-reachability/conformance/orc_reference.py new file mode 100644 index 0000000..c9afbf9 --- /dev/null +++ b/standards/agent-continuity/handoff-reachability/conformance/orc_reference.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from typing import Any, Iterable, Mapping + + +def _same_authority(a: Mapping[str, Any], b: Mapping[str, Any]) -> bool: + return ( + a.get("authority_lane_id") == b.get("authority_lane_id") + and a.get("authority_owner_ref") == b.get("authority_owner_ref") + and a.get("authority_epoch") == b.get("authority_epoch") + and a.get("root_execution_id") == b.get("root_execution_id") + ) + + +def evaluate_side_effect( + state: Mapping[str, Any], + execution: Mapping[str, Any], + parent: Mapping[str, Any] | None = None, +) -> tuple[str, dict[str, Any]]: + """Evaluate whether an execution still has authority for a side effect. + + The current lane state is assumed to come from the HRC ownership state. + ORC intentionally treats process liveness as independent from admission. + """ + + if execution.get("authority_lane_id") != state.get("lane_id"): + return "BLOCKED_LANE_MISMATCH", {} + + if parent is not None: + if execution.get("parent_execution_id") != parent.get("execution_id"): + return "BLOCKED_PARENT_EXECUTION_MISMATCH", {} + if not _same_authority(execution, parent): + return "BLOCKED_AUTHORITY_LINEAGE_ESCAPE", { + "parent_execution_id": parent.get("execution_id"), + "execution_id": execution.get("execution_id"), + } + + bound_epoch = execution.get("authority_epoch") + current_epoch = state.get("ownership_epoch") + if bound_epoch != current_epoch: + return "BLOCKED_REVOKED_AUTHORITY_EPOCH", { + "bound_authority_epoch": bound_epoch, + "current_ownership_epoch": current_epoch, + } + + bound_owner = execution.get("authority_owner_ref") + current_owner = state.get("owner_ref") + if bound_owner != current_owner: + return "BLOCKED_REVOKED_AUTHORITY_OWNER", { + "bound_authority_owner_ref": bound_owner, + "current_owner_ref": current_owner, + } + + return "SIDE_EFFECT_ALLOWED", { + "execution_id": execution.get("execution_id"), + "authority_owner_ref": bound_owner, + "authority_epoch": bound_epoch, + } + + +def evaluate_revocation_quiescence( + state: Mapping[str, Any], + executions: Iterable[Mapping[str, Any]], +) -> tuple[str, dict[str, Any]]: + """Report whether executions from superseded authority are observed stopped. + + KILL_REQUESTED and UNKNOWN remain non-quiescent. This function does not + decide side-effect authority; evaluate_side_effect does that independently. + """ + + revoked: list[str] = [] + live: list[str] = [] + + for execution in executions: + if execution.get("authority_lane_id") != state.get("lane_id"): + continue + + stale = ( + execution.get("authority_epoch") != state.get("ownership_epoch") + or execution.get("authority_owner_ref") != state.get("owner_ref") + ) + if not stale: + continue + + execution_id = str(execution.get("execution_id")) + revoked.append(execution_id) + if execution.get("process_state") != "EXITED": + live.append(execution_id) + + if live: + return "REVOCATION_PENDING_LIVE_EXECUTIONS", { + "revoked_execution_ids": revoked, + "live_execution_ids": live, + } + + return "REVOCATION_QUIESCENT", { + "revoked_execution_ids": revoked, + "live_execution_ids": [], + } diff --git a/standards/agent-continuity/handoff-reachability/conformance/test_orc_conformance.py b/standards/agent-continuity/handoff-reachability/conformance/test_orc_conformance.py new file mode 100644 index 0000000..722b35e --- /dev/null +++ b/standards/agent-continuity/handoff-reachability/conformance/test_orc_conformance.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import unittest + +from orc_reference import evaluate_revocation_quiescence, evaluate_side_effect + + +class ORCConformanceTests(unittest.TestCase): + def state_a4(self): + return { + "lane_id": "lane-alpha", + "owner_ref": "agent:A", + "ownership_epoch": 4, + "head_event_id": "E42", + } + + def state_b5(self): + return { + "lane_id": "lane-alpha", + "owner_ref": "agent:B", + "ownership_epoch": 5, + "head_event_id": "E43", + } + + def root_a(self, process_state="RUNNING"): + return { + "execution_id": "exec:A-root", + "actor_ref": "agent:A", + "authority_lane_id": "lane-alpha", + "authority_owner_ref": "agent:A", + "authority_epoch": 4, + "root_execution_id": "exec:A-root", + "parent_execution_id": None, + "process_group_ref": "pg:A", + "process_state": process_state, + } + + def child_c(self, process_state="RUNNING"): + return { + "execution_id": "exec:C-child", + "actor_ref": "agent:C", + "authority_lane_id": "lane-alpha", + "authority_owner_ref": "agent:A", + "authority_epoch": 4, + "root_execution_id": "exec:A-root", + "parent_execution_id": "exec:A-root", + "process_group_ref": "pg:A", + "process_state": process_state, + } + + def grandchild_d(self, process_state="RUNNING"): + return { + "execution_id": "exec:D-grandchild", + "actor_ref": "agent:D", + "authority_lane_id": "lane-alpha", + "authority_owner_ref": "agent:A", + "authority_epoch": 4, + "root_execution_id": "exec:A-root", + "parent_execution_id": "exec:C-child", + "process_group_ref": "pg:A", + "process_state": process_state, + } + + def root_b(self, process_state="RUNNING"): + return { + "execution_id": "exec:B-root", + "actor_ref": "agent:B", + "authority_lane_id": "lane-alpha", + "authority_owner_ref": "agent:B", + "authority_epoch": 5, + "root_execution_id": "exec:B-root", + "parent_execution_id": None, + "process_group_ref": "pg:B", + "process_state": process_state, + } + + def test_01_current_root_is_admitted(self): + status, _ = evaluate_side_effect(self.state_a4(), self.root_a()) + self.assertEqual(status, "SIDE_EFFECT_ALLOWED") + + def test_02_current_child_inherits_authority_and_is_admitted(self): + status, _ = evaluate_side_effect(self.state_a4(), self.child_c(), self.root_a()) + self.assertEqual(status, "SIDE_EFFECT_ALLOWED") + + def test_03_handoff_immediately_revokes_old_root_even_if_running(self): + status, detail = evaluate_side_effect(self.state_b5(), self.root_a()) + self.assertEqual(status, "BLOCKED_REVOKED_AUTHORITY_EPOCH") + self.assertEqual(detail["bound_authority_epoch"], 4) + self.assertEqual(detail["current_ownership_epoch"], 5) + + def test_04_handoff_immediately_revokes_running_child(self): + status, _ = evaluate_side_effect(self.state_b5(), self.child_c(), self.root_a()) + self.assertEqual(status, "BLOCKED_REVOKED_AUTHORITY_EPOCH") + + def test_05_handoff_immediately_revokes_running_grandchild(self): + status, _ = evaluate_side_effect(self.state_b5(), self.grandchild_d(), self.child_c()) + self.assertEqual(status, "BLOCKED_REVOKED_AUTHORITY_EPOCH") + + def test_06_child_cannot_self_refresh_to_new_epoch_inside_old_lineage(self): + child = self.child_c() + child["authority_owner_ref"] = "agent:B" + child["authority_epoch"] = 5 + status, _ = evaluate_side_effect(self.state_b5(), child, self.root_a()) + self.assertEqual(status, "BLOCKED_AUTHORITY_LINEAGE_ESCAPE") + + def test_07_child_cannot_change_root_identity_to_escape_revocation(self): + child = self.child_c() + child["root_execution_id"] = "exec:B-root" + status, _ = evaluate_side_effect(self.state_a4(), child, self.root_a()) + self.assertEqual(status, "BLOCKED_AUTHORITY_LINEAGE_ESCAPE") + + def test_08_wrong_parent_binding_is_blocked(self): + child = self.child_c() + child["parent_execution_id"] = "exec:other" + status, _ = evaluate_side_effect(self.state_a4(), child, self.root_a()) + self.assertEqual(status, "BLOCKED_PARENT_EXECUTION_MISMATCH") + + def test_09_new_owner_new_lineage_is_admitted(self): + status, _ = evaluate_side_effect(self.state_b5(), self.root_b()) + self.assertEqual(status, "SIDE_EFFECT_ALLOWED") + + def test_10_kill_requested_is_not_quiescent(self): + root = self.root_a(process_state="KILL_REQUESTED") + child = self.child_c(process_state="RUNNING") + status, detail = evaluate_revocation_quiescence(self.state_b5(), [root, child]) + self.assertEqual(status, "REVOCATION_PENDING_LIVE_EXECUTIONS") + self.assertCountEqual(detail["live_execution_ids"], ["exec:A-root", "exec:C-child"]) + + def test_11_exited_root_but_live_orphan_child_is_not_quiescent(self): + root = self.root_a(process_state="EXITED") + child = self.child_c(process_state="RUNNING") + status, detail = evaluate_revocation_quiescence(self.state_b5(), [root, child]) + self.assertEqual(status, "REVOCATION_PENDING_LIVE_EXECUTIONS") + self.assertEqual(detail["live_execution_ids"], ["exec:C-child"]) + + def test_12_all_revoked_descendants_observed_exited_is_quiescent(self): + executions = [ + self.root_a(process_state="EXITED"), + self.child_c(process_state="EXITED"), + self.grandchild_d(process_state="EXITED"), + ] + status, detail = evaluate_revocation_quiescence(self.state_b5(), executions) + self.assertEqual(status, "REVOCATION_QUIESCENT") + self.assertEqual(detail["live_execution_ids"], []) + self.assertCountEqual( + detail["revoked_execution_ids"], + ["exec:A-root", "exec:C-child", "exec:D-grandchild"], + ) + + def test_13_current_new_owner_does_not_count_as_revoked_for_quiescence(self): + status, detail = evaluate_revocation_quiescence(self.state_b5(), [self.root_b()]) + self.assertEqual(status, "REVOCATION_QUIESCENT") + self.assertEqual(detail["revoked_execution_ids"], []) + + def test_14_unknown_stale_process_state_fails_closed_for_quiescence(self): + stale = self.child_c(process_state="UNKNOWN") + status, detail = evaluate_revocation_quiescence(self.state_b5(), [stale]) + self.assertEqual(status, "REVOCATION_PENDING_LIVE_EXECUTIONS") + self.assertEqual(detail["live_execution_ids"], ["exec:C-child"]) + + def test_15_owner_mismatch_is_blocked_even_when_epoch_matches(self): + state = self.state_a4() + execution = self.root_a() + execution["authority_owner_ref"] = "agent:X" + status, _ = evaluate_side_effect(state, execution) + self.assertEqual(status, "BLOCKED_REVOKED_AUTHORITY_OWNER") + + def test_16_lane_mismatch_is_blocked(self): + execution = self.root_a() + execution["authority_lane_id"] = "lane-other" + status, _ = evaluate_side_effect(self.state_a4(), execution) + self.assertEqual(status, "BLOCKED_LANE_MISMATCH") + + +if __name__ == "__main__": + unittest.main()