From 6292efdf34e8a52c0baa7fe83a82823e1b54256c Mon Sep 17 00:00:00 2001 From: prakashUXtech Date: Mon, 25 May 2026 22:47:52 +0530 Subject: [PATCH] feat(spec): add decision.completed + build_policy_event + build_completion_event for RFC 09 RFC 09's Decision Graph projection needs a chain-closing terminal event, and earlier drafts were reusing decision.graduated for that role. That collides with decision.graduated's existing meaning (pattern promotion from episodic to semantic memory), so this slice splits the vocabulary: decision.completed becomes the canonical chain terminator while decision.graduated keeps its original promotion semantics unchanged. Adds two builders alongside the existing build_proposal_event / build_correction_event pair: build_policy_event for the policy.evaluated chain-forming event (Instinct gate evaluations) and build_completion_event for decision.completed with a landed/rejected/abandoned status. trace_decision_chain's filter is extended to follow both new actions while keeping decision.graduated in scope for backward-compat queries. Additive: no shipped action names changed, no signatures rewritten. Refs paw-workspace#63 (RFC 09 Slice 1a). --- src/soul_protocol/spec/__init__.py | 14 ++ src/soul_protocol/spec/decisions.py | 139 ++++++++++++++++ src/soul_protocol/spec/journal.py | 22 +++ tests/test_spec/test_decisions.py | 239 ++++++++++++++++++++++++++++ 4 files changed, 414 insertions(+) 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 --------------------------------------------