diff --git a/src/soul_protocol/spec/__init__.py b/src/soul_protocol/spec/__init__.py index 5a86367..0b3ce0d 100644 --- a/src/soul_protocol/spec/__init__.py +++ b/src/soul_protocol/spec/__init__.py @@ -29,6 +29,11 @@ # Ed25519 implementation lives under runtime/crypto/. # Updated: 2026-04-29 (#203) — Exported CHAIN_PRUNED_ACTION for the touch-time # pruning marker. Verifier carve-out lives in spec.trust.verify_entry. +# Updated: feat/rfc-09-slice-1-decision-vocabulary — Exported the RFC 09 +# Decision Graph builders and type alias: ``build_policy_event``, +# ``build_completion_event``, ``CompletionStatus``. The actions they emit +# (``policy.evaluated`` was already in ACTION_NAMESPACES; ``decision.completed`` +# is added in this slice) close the Decision Graph chain vocabulary. from __future__ import annotations @@ -46,10 +51,13 @@ ) from .decisions import ( AgentProposal, + CompletionStatus, DecisionGraduation, Disposition, HumanCorrection, + build_completion_event, build_correction_event, + build_policy_event, build_proposal_event, cluster_correction_patterns, find_corrections_for, @@ -151,6 +159,12 @@ "find_corrections_for", "trace_decision_chain", "cluster_correction_patterns", + # RFC 09 Decision Graph projection builders (policy.evaluated + + # decision.completed). ``CompletionStatus`` is the Literal alias + # used by ``build_completion_event``. + "CompletionStatus", + "build_policy_event", + "build_completion_event", # Memory "Interaction", "MemoryEntry", diff --git a/src/soul_protocol/spec/decisions.py b/src/soul_protocol/spec/decisions.py index 5afa3ad..3688c71 100644 --- a/src/soul_protocol/spec/decisions.py +++ b/src/soul_protocol/spec/decisions.py @@ -1,4 +1,16 @@ # decisions.py — Decision trace payload types and helpers for the Org Journal. +# Updated: feat/rfc-09-slice-1-decision-vocabulary — add two new builders for +# the Decision Graph projection vocabulary (RFC 09 Slice 1a): +# - ``build_policy_event`` for the ``policy.evaluated`` chain-forming event +# (Instinct gate evaluations) — the LAST one before chain close becomes +# ``Decision.instinct_policy`` in the projection. +# - ``build_completion_event`` for the ``decision.completed`` terminal +# event — the canonical chain closer per RFC 09 with a +# ``landed`` / ``rejected`` / ``abandoned`` status. +# Also extends ``trace_decision_chain``'s filter to follow +# ``decision.completed`` (kept ``decision.graduated`` in the filter — that +# action is a separate concept, see comment on the filter and on +# ``ACTION_NAMESPACES`` in journal.py). # Updated: feat/rfc07-decision-outcome-attached — extend the # ``trace_decision_chain`` decision-action filter to include # ``decision.outcome_attached`` so post-close outcome mutations @@ -189,6 +201,119 @@ def build_correction_event( ) +CompletionStatus = Literal["landed", "rejected", "abandoned"] +"""Terminal status emitted on a ``decision.completed`` event (RFC 09). + +- ``landed``: the chain reached its intended outcome (action taken, decision + applied, draft sent). +- ``rejected``: the chain closed because a reviewer / policy rejected the + proposal. +- ``abandoned``: the chain closed without an explicit outcome — timed out, + superseded, or otherwise dropped. +""" + + +def build_policy_event( + *, + actor: Actor, + scope: list[str], + correlation_id: UUID, + policy_name: str, + passed: bool, + causation_id: UUID | None = None, + reason: str | None = None, + payload_extras: dict | None = None, + ts: datetime | None = None, + event_id: UUID | None = None, +) -> EventEntry: + """Build a ``policy.evaluated`` :class:`EventEntry` for the Decision + Graph chain (RFC 09). + + Producer is Instinct's gate evaluator. Each policy run produces one + event; the LAST ``policy.evaluated`` before chain close becomes the + projection's ``Decision.instinct_policy`` field. + + ``causation_id`` typically points back at the preceding + ``agent.proposed`` event the policy was evaluated against; it is + optional because Instinct can also run pre-proposal sweeps that have + no upstream cause in the chain. + + ``payload_extras`` is merged on top of the base payload + (``{policy_name, passed, reason?}``) — use it to carry evaluator + metadata (rule id, version, evaluator name, latency) without + growing the public signature. + """ + payload: dict[str, object] = {"policy_name": policy_name, "passed": passed} + if reason is not None: + payload["reason"] = reason + if payload_extras: + # Caller extras win — they're the more specific layer. The base + # keys (``policy_name``, ``passed``, ``reason``) are conventions, + # not invariants, so an extras dict that overrides them is a + # legitimate (if unusual) shape. + payload.update(payload_extras) + return EventEntry( + id=event_id or uuid4(), + ts=ts or _now_utc(), + actor=actor, + action="policy.evaluated", + scope=scope, + correlation_id=correlation_id, + causation_id=causation_id, + payload=payload, + ) + + +def build_completion_event( + *, + actor: Actor, + scope: list[str], + correlation_id: UUID, + causation_id: UUID | None = None, + status: CompletionStatus = "landed", + reason: str | None = None, + payload_extras: dict | None = None, + ts: datetime | None = None, + event_id: UUID | None = None, +) -> EventEntry: + """Build a ``decision.completed`` :class:`EventEntry` — the canonical + chain terminator per RFC 09. + + Replaces ``decision.graduated`` for Decision Graph chain-close + purposes. ``decision.graduated`` retains its original meaning + (pattern promotion into semantic / core memory, see + :class:`DecisionGraduation`) and is unchanged. + + ``causation_id`` typically points back at the last meaningful event in + the chain (the accepted ``human.corrected``, the final + ``policy.evaluated``, etc.) but is optional — abandoned / timed-out + chains may not have a clear causal predecessor. + + ``status`` defaults to ``"landed"`` (the common happy-path); set to + ``"rejected"`` or ``"abandoned"`` for negative closures, and populate + ``reason`` to record why. + + ``payload_extras`` merges on top of ``{status, reason?}`` — use it to + attach summary metadata (final outcome ref, downstream side-effect + ids) without growing the public signature. + """ + payload: dict[str, object] = {"status": status} + if reason is not None: + payload["reason"] = reason + if payload_extras: + payload.update(payload_extras) + return EventEntry( + id=event_id or uuid4(), + ts=ts or _now_utc(), + actor=actor, + action="decision.completed", + scope=scope, + correlation_id=correlation_id, + causation_id=causation_id, + payload=payload, + ) + + # ----- Journal queries ----------------------------------------------------- @@ -219,11 +344,22 @@ def trace_decision_chain(journal, correlation_id: UUID) -> list[EventEntry]: decision_actions = { "agent.proposed", "human.corrected", + # ``decision.graduated`` keeps its original (pattern-promotion) + # meaning and stays in the filter so backward-compat chain + # traces still surface graduation events on the same + # correlation_id. RFC 09's chain-closing terminal is + # ``decision.completed`` (below), not this one. "decision.graduated", # RFC 07: outcome-attachment events update an already-emitted # Decision's outcome and belong in the chain so traces surface # the full lifecycle, including post-close mutations. "decision.outcome_attached", + # RFC 09: Instinct-gate evaluations are part of the chain — the + # last ``policy.evaluated`` before close becomes the + # projection's ``Decision.instinct_policy``. + "policy.evaluated", + # RFC 09: canonical chain terminator. See ``build_completion_event``. + "decision.completed", } chain = [e for e in events if e.action in decision_actions] chain.sort(key=lambda e: e.ts) @@ -286,8 +422,11 @@ def cluster_correction_patterns( "HumanCorrection", "DecisionGraduation", "Disposition", + "CompletionStatus", "build_proposal_event", "build_correction_event", + "build_policy_event", + "build_completion_event", "find_corrections_for", "trace_decision_chain", "cluster_correction_patterns", diff --git a/src/soul_protocol/spec/journal.py b/src/soul_protocol/spec/journal.py index 8795117..954953d 100644 --- a/src/soul_protocol/spec/journal.py +++ b/src/soul_protocol/spec/journal.py @@ -1,4 +1,12 @@ # journal.py — Org Journal primitives (Actor, DataRef, EventEntry). +# Updated: feat/rfc-09-slice-1-decision-vocabulary — register +# ``decision.completed`` in ACTION_NAMESPACES as the canonical +# chain-closing terminal event for Decision Graph chains (RFC 09). +# Kept ``decision.graduated`` alongside it; that action retains its +# original meaning (pattern promotion from episodic to semantic +# memory) and is unchanged. The vocabulary split resolves a collision +# RFC 09 surfaced — earlier drafts were reusing ``decision.graduated`` +# for two different purposes. # Updated: feat/rfc07-decision-outcome-attached — register # ``decision.outcome_attached`` in ACTION_NAMESPACES. RFC 07 introduces # this action as a projection-update event that mutates a Decision's @@ -105,6 +113,12 @@ def _encode_bytes(v: bytes | None) -> str | None: # Decisions "agent.proposed", "human.corrected", + # ``decision.graduated`` (original meaning, unchanged): a recurring + # correction pattern has been promoted from episodic to semantic / + # core memory. Producer: the graduation subsystem. See + # :class:`soul_protocol.spec.decisions.DecisionGraduation`. Kept in + # the catalog for backward compatibility — RFC 09's chain-closing + # event is ``decision.completed`` (below), not this one. "decision.graduated", # RFC 07 (Decision Graph Query Layer): projection-update event that # mutates an already-emitted Decision's ``outcome`` field after the @@ -112,6 +126,14 @@ def _encode_bytes(v: bytes | None) -> str | None: # post-hoc update is safe — it is the only event in the spec that # mutates a prior projection record. "decision.outcome_attached", + # RFC 09 (Decision Graph projection): the canonical chain-closing + # terminal event for a Decision Graph chain. Emitted once per + # closed chain with a ``status`` of ``landed`` / ``rejected`` / + # ``abandoned``. Distinct from ``decision.graduated`` above — that + # action means pattern promotion into semantic memory and operates + # on a different subsystem. See + # :func:`soul_protocol.spec.decisions.build_completion_event`. + "decision.completed", # Credentials & Zero-Copy "credential.acquired", "credential.used", diff --git a/tests/test_spec/test_decisions.py b/tests/test_spec/test_decisions.py index 3623483..08b422f 100644 --- a/tests/test_spec/test_decisions.py +++ b/tests/test_spec/test_decisions.py @@ -1,4 +1,10 @@ # test_spec/test_decisions.py — Tests for the decision-trace spec. +# Updated: feat/rfc-09-slice-1-decision-vocabulary — cover the RFC 09 +# Decision Graph builders: ``decision.completed`` registration in +# ACTION_NAMESPACES, ``build_policy_event`` payload shape, three +# completion-status variants for ``build_completion_event``, and the +# ``trace_decision_chain`` filter extension that follows +# ``decision.completed`` (kept ``decision.graduated`` behavior intact). # Created: feat/decision-traces — Workstream D of Org Architecture RFC (PR #164). # Covers: JSON round-trips, disposition Literal enforcement, builder helpers # emit the right actions + payload shapes, find_corrections_for filters on @@ -22,7 +28,9 @@ AgentProposal, DecisionGraduation, HumanCorrection, + build_completion_event, build_correction_event, + build_policy_event, build_proposal_event, cluster_correction_patterns, find_corrections_for, @@ -156,6 +164,14 @@ def test_namespaces_include_decision_actions(): # Decision's outcome field. Registered additively in the journal's # action catalog so the chain projection can recognize it. assert "decision.outcome_attached" in ACTION_NAMESPACES + # RFC 09 — canonical chain-closing terminal event for Decision + # Graph chains. Distinct from ``decision.graduated`` (kept above + # for backward compat with the pattern-promotion subsystem). + assert "decision.completed" in ACTION_NAMESPACES + # RFC 09 chain-forming event (Instinct gate evaluations) — already + # in the catalog under the Graduation & Policy block, asserted here + # to lock the vocabulary alongside the RFC 09 builders. + assert "policy.evaluated" in ACTION_NAMESPACES def test_build_proposal_event_shape(drafter: Actor): @@ -202,6 +218,124 @@ def test_build_correction_event_shape(reviewer: Actor): assert event.payload["disposition"] == "edited" +# ---------- RFC 09 builders ----------------------------------------------- + + +def test_build_policy_event_shape(drafter: Actor): + correlation_id = uuid4() + proposal_id = uuid4() + event = build_policy_event( + actor=drafter, + scope=["org:sales:pocket:acme"], + correlation_id=correlation_id, + policy_name="no_outbound_after_hours", + passed=True, + causation_id=proposal_id, + reason="within business hours", + ) + assert event.action == "policy.evaluated" + assert event.correlation_id == correlation_id + assert event.causation_id == proposal_id + assert isinstance(event.payload, dict) + assert event.payload["policy_name"] == "no_outbound_after_hours" + assert event.payload["passed"] is True + assert event.payload["reason"] == "within business hours" + assert event.ts.tzinfo is not None + + +def test_build_policy_event_omits_optional_reason_when_none(drafter: Actor): + event = build_policy_event( + actor=drafter, + scope=["org:sales"], + correlation_id=uuid4(), + policy_name="rate_limit_ok", + passed=False, + ) + assert isinstance(event.payload, dict) + assert "reason" not in event.payload + assert event.payload["passed"] is False + # causation_id is optional for pre-proposal policy sweeps that have + # no upstream cause in the chain — must default to None. + assert event.causation_id is None + + +def test_build_policy_event_merges_payload_extras(drafter: Actor): + event = build_policy_event( + actor=drafter, + scope=["org:sales"], + correlation_id=uuid4(), + policy_name="pii_filter", + passed=True, + payload_extras={"rule_id": "pii-v3", "latency_ms": 12}, + ) + assert isinstance(event.payload, dict) + assert event.payload["rule_id"] == "pii-v3" + assert event.payload["latency_ms"] == 12 + # Base keys remain untouched when extras don't conflict. + assert event.payload["policy_name"] == "pii_filter" + + +def test_build_completion_event_defaults_to_landed(reviewer: Actor): + correlation_id = uuid4() + causation_id = uuid4() + event = build_completion_event( + actor=reviewer, + scope=["org:sales:pocket:acme"], + correlation_id=correlation_id, + causation_id=causation_id, + ) + assert event.action == "decision.completed" + assert event.correlation_id == correlation_id + assert event.causation_id == causation_id + assert isinstance(event.payload, dict) + assert event.payload["status"] == "landed" + # Optional reason omitted when not supplied — keeps payloads tight. + assert "reason" not in event.payload + assert event.ts.tzinfo is not None + + +def test_build_completion_event_supports_rejected_with_reason(reviewer: Actor): + event = build_completion_event( + actor=reviewer, + scope=["org:sales"], + correlation_id=uuid4(), + status="rejected", + reason="policy gate failed: rate_limit", + ) + assert isinstance(event.payload, dict) + assert event.payload["status"] == "rejected" + assert event.payload["reason"] == "policy gate failed: rate_limit" + + +def test_build_completion_event_supports_abandoned_without_causation(reviewer: Actor): + # Abandoned chains (timed out, superseded) may have no clear causal + # predecessor — causation_id must be allowed to be omitted. + event = build_completion_event( + actor=reviewer, + scope=["org:sales"], + correlation_id=uuid4(), + status="abandoned", + reason="timed out after 24h", + ) + assert event.causation_id is None + assert isinstance(event.payload, dict) + assert event.payload["status"] == "abandoned" + + +def test_build_completion_event_merges_payload_extras(reviewer: Actor): + event = build_completion_event( + actor=reviewer, + scope=["org:sales"], + correlation_id=uuid4(), + status="landed", + payload_extras={"outcome_ref": "email:msg-123", "side_effects": ["sent"]}, + ) + assert isinstance(event.payload, dict) + assert event.payload["outcome_ref"] == "email:msg-123" + assert event.payload["side_effects"] == ["sent"] + assert event.payload["status"] == "landed" + + # ---------- journal queries ----------------------------------------------- @@ -364,6 +498,111 @@ def test_trace_decision_chain_filters_non_decision_events( assert actions == ["agent.proposed", "human.corrected"] +def test_trace_decision_chain_follows_rfc_09_actions( + journal: Journal, drafter: Actor, reviewer: Actor +): + """RFC 09: ``policy.evaluated`` and ``decision.completed`` are part of + the Decision Graph chain and must surface in ``trace_decision_chain`` + alongside the existing proposed/corrected/graduated events.""" + correlation_id = uuid4() + base = datetime.now(UTC) + + p = build_proposal_event( + actor=drafter, + scope=["org:sales"], + correlation_id=correlation_id, + proposal=AgentProposal( + proposal_kind="message_draft", summary="s", proposal={"body": "draft"} + ), + ts=base, + ) + journal.append(p) + pol = build_policy_event( + actor=drafter, + scope=["org:sales"], + correlation_id=correlation_id, + policy_name="no_outbound_after_hours", + passed=True, + causation_id=p.id, + ts=base + timedelta(seconds=1), + ) + journal.append(pol) + c = build_correction_event( + actor=reviewer, + scope=["org:sales"], + correlation_id=correlation_id, + causation_id=p.id, + correction=HumanCorrection( + disposition="accepted", + corrected_value={"body": "draft"}, + structured_reason_tags=[], + ), + ts=base + timedelta(seconds=2), + ) + journal.append(c) + done = build_completion_event( + actor=reviewer, + scope=["org:sales"], + correlation_id=correlation_id, + causation_id=c.id, + status="landed", + ts=base + timedelta(seconds=3), + ) + journal.append(done) + + chain = trace_decision_chain(journal, correlation_id) + actions = [e.action for e in chain] + assert actions == [ + "agent.proposed", + "policy.evaluated", + "human.corrected", + "decision.completed", + ] + + +def test_trace_decision_chain_still_follows_decision_graduated( + journal: Journal, drafter: Actor, reviewer: Actor +): + """Backward-compat: ``decision.graduated`` keeps its original + (pattern-promotion) meaning and stays in the chain filter so older + queries that emit graduation events on a correlation_id still + surface them.""" + from soul_protocol.spec.journal import EventEntry + + correlation_id = uuid4() + base = datetime.now(UTC) + + p, c = _seed_proposal_and_correction( + journal, + drafter=drafter, + reviewer=reviewer, + correlation_id=correlation_id, + proposal_ts=base, + correction_ts=base + timedelta(seconds=1), + ) + grad_payload = DecisionGraduation( + pattern_summary="Use first names internally.", + supporting_correction_ids=[c.id], + graduated_to_tier="semantic", + confidence=0.9, + ) + grad = EventEntry( + id=uuid4(), + ts=base + timedelta(seconds=2), + actor=drafter, + action="decision.graduated", + scope=["org:sales"], + correlation_id=correlation_id, + payload=grad_payload.model_dump(mode="json"), + ) + journal.append(grad) + + chain = trace_decision_chain(journal, correlation_id) + actions = [e.action for e in chain] + assert "decision.graduated" in actions + assert actions[-1] == "decision.graduated" + + # ---------- pattern clustering --------------------------------------------