Skip to content

feat(events): add control.directive.emitted event factory#478

Open
shaun0927 wants to merge 5 commits intoQ00:mainfrom
shaun0927:feat/events-control-directive
Open

feat(events): add control.directive.emitted event factory#478
shaun0927 wants to merge 5 commits intoQ00:mainfrom
shaun0927:feat/events-control-directive

Conversation

@shaun0927
Copy link
Copy Markdown
Collaborator

Summary

  • Add ouroboros.events.control.create_control_directive_emitted_event — a factory that persists control-plane decisions (continue / evaluate / evolve / unstuck / retry / compact / wait / cancel / converge) to the EventStore via the existing BaseEvent contract.
  • Pure addition — no emission site is added in this PR. Wiring each source lands as a separate reviewable change.

Depends on #477 (the Directive type). This PR is currently stacked on that branch; once #477 merges, this PR's diff reduces to only the new event factory.

Motivation

First step of #473 under the control-plane framing in #471. The repo's event-sourcing story is strong — decomposition, evaluation, interview, lineage, and ontology categories together reconstruct prior state — but there is no category for the decisions themselves. Today, why a run moved from evaluate to evolve, or why a retry was issued, is implied by downstream effects and visible only in logs.

This is the reason the TUI lineage screen can show phase progress but not the decisions that produced it, and the reason the Directive type introduced in #472 would otherwise be ephemeral.

The factory follows the same surgical-addition pattern as #436 (feat(events): add event_version to BaseEvent payload) and #443 (feat(evaluate): add ouroboros_checklist_verify MCP tool): a new primitive introduced alongside its tests, with no existing caller modified.

Changes

  • src/ouroboros/events/control.py — new module.
    • create_control_directive_emitted_event(execution_id, emitted_by, directive, reason, context_snapshot_id=None, extra=None).
    • Emits a BaseEvent with type="control.directive.emitted", aggregate_type="control", aggregate_id=execution_id.
    • Payload denormalizes directive.value and is_terminal so downstream consumers (TUI, drift detector, future replay) classify events without importing Directive.
    • context_snapshot_id and extra stay out of the payload when unset — matching the "compact payload" convention used by events/evaluation.py and events/decomposition.py.
  • tests/unit/events/test_control_events.py — 10 tests covering event type, aggregate shape, StrEnum serialization, terminality, optional-field compaction, and coverage of every Directive member (guards future vocabulary growth).

Not changed

  • No emission site is wired up in this PR. Wiring is a separate concern and becomes its own small PR (e.g., feat(evaluation): emit control.directive.emitted from terminal branches).
  • No Alembic migration is required — BaseEvent.to_db_dict already stores arbitrary payloads. Schema-level work (a dedicated control_directive table, if the maintainers prefer one over the generic events table) is deferred to a follow-up.
  • No TUI rendering change.

Verification

uv run ruff check src/ouroboros/events/control.py tests/unit/events/test_control_events.py
uv run ruff format --check src/ouroboros/events/control.py tests/unit/events/test_control_events.py
uv run pytest tests/unit/events/ tests/unit/core/ -q

Result: 343 passed (10 new), ruff clean, format clean.

References

Add ouroboros.core.directive as an additive, caller-free vocabulary for
control-plane decisions (continue / evaluate / evolve / unstuck / retry /
compact / wait / cancel / converge).

Decision sites are currently distributed across evaluation/, evolution/,
resilience/, orchestrator/, and observability/, each with its own ad-hoc
signals. This commit introduces only the shared type and its terminal
set; no decision site is modified. Follow-up changes will migrate sites
incrementally so each migration is independently reviewable.

- src/ouroboros/core/directive.py: Directive StrEnum, per-member docstrings,
  and a module-level _TERMINAL_DIRECTIVES frozenset exposed through
  is_terminal (CANCEL, CONVERGE).
- src/ouroboros/core/__init__.py: lazy re-export of Directive.
- tests/unit/core/test_directive.py: value/terminality/membership/re-export
  coverage (10 tests).

Verification:
- uv run ruff check src/ouroboros/core/directive.py src/ouroboros/core/__init__.py tests/unit/core/test_directive.py
- uv run ruff format --check <same paths>
- uv run pytest tests/unit/core/ -q  # 296 passed
Copy link
Copy Markdown
Contributor

@ouroboros-agent ouroboros-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — ouroboros-agent[bot]

Verdict: APPROVE

Reviewing commit 62cbdac for PR #478

Review record: 20cb6db7-b282-4885-b998-ab03dd57dc2f

Blocking Findings

No in-scope blocking findings remained after policy filtering.

Non-blocking Suggestions

None.

Design Notes

This is an additive, low-risk change: a shared Directive vocabulary plus a control-event factory with focused unit coverage for enum stability and payload shape. I did not find a scope-local contract mismatch or production bug in the touched files; note that I could not execute the tests here because pytest is not installed in the review environment.

Recovery Notes

First recoverable review artifact generated from codex analysis log.


Reviewed by ouroboros-agent[bot] via Codex deep analysis

@shaun0927
Copy link
Copy Markdown
Collaborator Author

Aligning with #476 (Phase 2 framing): this PR implements the observational-first stance per Q3 at #476#issuecomment-4294892689 — no emission site, no reactive consumer, payload denormalizes directive.value + is_terminal so future projections don't need to import the enum.

Reactive consumption (ControlBus subscription) is a follow-up, not this PR's concern.

No code change needed. ouroboros-agent APPROVED, all CI green.

Expand the module docstring to connect Directive explicitly to the Agent
OS layering introduced by the Phase 2 RFC (Q00#476): directives occupy the
control layer and are the runtime-level syscalls through which decision
sites express control flow.

Also record two posture decisions that were implicit before:

- Directives describe workflow control, not capability policy. Capability
  visibility/execution decisions stay in the policy layer and in
  policy.* events, never interleaved with directive semantics.
- Existing local enums (e.g. StepAction) are mapped into this vocabulary
  incrementally rather than replaced in a single change. The docstring
  records the reference StepAction -> Directive mapping so callers and
  reviewers can see the migration path at the type's site of truth.

No behavior or API change.
Add ouroboros.events.control as the first event category capturing
decision causality. Existing categories (decomposition, evaluation,
interview, lineage, ontology) record what was produced; this factory
records why the run advanced from one step to the next.

- src/ouroboros/events/control.py: create_control_directive_emitted_event
  factory emitting type 'control.directive.emitted' aggregated by
  execution id. Payload denormalizes directive.value and is_terminal so
  downstream consumers classify events without importing the Directive
  enum. Optional context_snapshot_id and extra fields stay out of the
  payload when unset to keep persisted rows compact.
- tests/unit/events/test_control_events.py: 10 tests covering event
  type, aggregate shape, StrEnum serialization, terminality flag,
  optional fields, and coverage of every Directive member.

No production emission is added in this change; emission sites are
migrated in follow-up PRs. Depends on the Directive type added by the
parent PR for the control-plane vocabulary.

Verification:
- uv run ruff check src/ouroboros/events/control.py tests/unit/events/test_control_events.py
- uv run ruff format --check <same>
- uv run pytest tests/unit/events/ tests/unit/core/ -q
Expand the module docstring to position this factory explicitly inside
the Agent OS Event Journal layer introduced by the Phase 2 RFC (Q00#476):
existing categories answer 'what was produced,' this category answers
'why the run moved,' and together they form a single replayable causal
timeline.

Also record the posture decisions that were implicit in the first draft:

- Observational-first: the event is the source of truth; reactive
  consumers (TUI lineage renderer, future ControlBus subscribers) are
  projections of persisted events, never upstream of them.
- Payload shape rationale: denormalizing directive.value and is_terminal
  lets downstream consumers classify events without importing the enum,
  keeping the projection layer decoupled from the vocabulary module.
- extra is a forward-compatibility slot, not a dumping ground: the
  docstring now states the preference for named arguments as fields
  stabilize.

No behavior or API change; existing tests continue to pass.
@shaun0927 shaun0927 force-pushed the feat/events-control-directive branch from 62cbdac to acee166 Compare April 22, 2026 09:04
@shaun0927
Copy link
Copy Markdown
Collaborator Author

Correction to my earlier alignment note — same reasoning as on #477. Bot APPROVAL verifies style, not whether the maintainer's Phase 2 framing is reflected in the primitive itself.

Re-reading events/control.py against #476 surfaced three gaps:

  1. Event Journal positioning: the module now states it corresponds to the Event Journal layer from the RFC, with the "what / why" split relative to existing categories.
  2. Observational-first as a commitment: previously only implicit. The docstring now says the event is the source of truth and any future reactive consumer (TUI lineage / ControlBus subscriber) is a projection of persisted events, never upstream of them.
  3. Payload shape rationale: why directive.value + is_terminal are denormalized (downstream classification without enum import), and why extra is a forward-compatibility slot rather than a dumping ground.

Branch rebased on top of the updated feat/core-directive-vocabulary to inherit the Directive docstring update. New commit: acee166 docs(events): tie control event factory to Phase 2 Event Journal framing — docstring only, no behavior change, tests pass (20/20 across core + events).

Copy link
Copy Markdown
Contributor

@ouroboros-agent ouroboros-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — ouroboros-agent[bot]

Verdict: APPROVE

Reviewing commit acee166 for PR #478

Review record: 1eb5a72d-cb7f-4cf6-8c3e-5d8b808e9f8c

Blocking Findings

No in-scope blocking findings remained after policy filtering.

Non-blocking Suggestions

| 1 | tests/unit/events/test_control_events.py:14 | nice-to-have tests | The new factory is only exercised at the in-memory BaseEvent level. A round-trip assertion through to_db_dict() / BaseEvent.from_db_row() would better lock in the stated JSON-safe payload contract for directive, is_terminal, and optional fields. |

Design Notes

The split is coherent: Directive establishes a small control vocabulary and events/control.py keeps the first integration observational-only. The new event payload is compact and forward-compatible, and the lazy re-export keeps the core package boundary consistent.

Recovery Notes

First recoverable review artifact generated from codex analysis log.


Reviewed by ouroboros-agent[bot] via Codex deep analysis

…00#476 review

Q00 flagged on Q00#476 that the initial event shape (aggregate_type='control',
aggregate_id=execution_id) stores decisions but does not let them surface
inside existing lineage/execution reconstructions. A projector querying
aggregate_type='lineage' never sees the interleaved directive stream,
so the event is persisted but not reconstructable as part of the
decision timeline.

This change adopts Q00's target-oriented contract:

  create_control_directive_emitted_event(
      target_type: str,            # 'session' | 'execution' | 'lineage' | 'agent_process'
      target_id: str,
      emitted_by: str,
      directive: Directive,
      reason: str,
      *,
      session_id: str | None = None,
      execution_id: str | None = None,
      lineage_id: str | None = None,
      generation_number: int | None = None,
      phase: str | None = None,
      context_snapshot_id: str | None = None,
      extra: dict | None = None,
  )

Stored event now has aggregate_type=target_type, aggregate_id=target_id,
so a LineageProjector reading aggregate_type='lineage' naturally includes
the directive stream alongside lineage.* state events. Correlation fields
(session_id, execution_id, lineage_id, generation_number, phase) are
optional and land in the payload only when provided, keeping rows
compact and absence distinguishable from explicit None.

target_type stays a free-form string (not an enum) so Phase 3's
'agent_process' target lands without a schema change.

- src/ouroboros/events/control.py: signature rework, docstring updated to
  explain target-oriented aggregation and the canonical target types.
- tests/unit/events/test_control_events.py: 17 tests covering target
  aggregation mirror, payload target duplication, optional correlation
  fields (session/execution/lineage/generation/phase/context_snapshot/
  extra), and forward-compatible target types ('agent_process', 'session').

Verification:
- uv run ruff check src/ouroboros/events/control.py tests/unit/events/test_control_events.py
- uv run ruff format --check <same>
- uv run pytest tests/unit/events/ tests/unit/core/ -q  # 350 passed
@shaun0927
Copy link
Copy Markdown
Collaborator Author

@Q00 — addressed your #476 feedback on the event contract. Commit ae8cb8c rewrites the factory around (target_type, target_id) so that decisions land inside the aggregate they describe, not in a separate control bucket.

What changed in this commit

New signature (matches your proposal verbatim):

create_control_directive_emitted_event(
    target_type: str,        # "session" | "execution" | "lineage" | later "agent_process"
    target_id: str,
    emitted_by: str,
    directive: Directive,
    reason: str,
    *,
    session_id: str | None = None,
    execution_id: str | None = None,
    lineage_id: str | None = None,
    generation_number: int | None = None,
    phase: str | None = None,
    context_snapshot_id: str | None = None,
    extra: dict[str, Any] | None = None,
)

Storage change:

  • aggregate_type = target_type
  • aggregate_id = target_id

That is the key move. A LineageProjector filtering aggregate_type="lineage" and aggregate_id=<lineage_id> now naturally picks up directive emissions alongside lineage.* state events, without adding a separate projector or a JOIN. The control event joins the lineage timeline by aggregation, not by correlation.

Payload shape mirrors your example:

{
  "target_type": "lineage",
  "target_id": "ralph-zepia-20260420-v3",
  "lineage_id": "ralph-zepia-20260420-v3",
  "generation_number": 2,
  "phase": "reflecting",
  "emitted_by": "evolver",
  "directive": "retry",
  "is_terminal": false,
  "reason": "Reflect failed; retry budget remains."
}

Optional correlation fields (session_id, execution_id, lineage_id, generation_number, phase, context_snapshot_id, extra) appear in the payload only when provided, so absence stays distinguishable from an explicit None and stored rows stay compact — matching the convention in events/evaluation.py and events/decomposition.py.

Target type is a free-form string rather than an enum. That preserves the invariant you called out: when Phase 3 introduces agent_process as a target, the event type and schema do not change — only the string set expands. A test case already exercises this (test_agent_process_target).

Reconstructable-timeline verification

The decision lane you described now reconstructs by a single aggregate filter:

SELECT * FROM events
WHERE aggregate_type = 'lineage'
  AND aggregate_id   = '<lineage_id>'
ORDER BY timestamp;

yields, for a ralph run:

lineage.created
lineage.generation.started            Gen 1 executing
lineage.generation.completed          Gen 1
control.directive.emitted   EVOLVE    reason="not converged; advance generation"
lineage.generation.started            Gen 2 wondering
lineage.generation.failed             Gen 2 reflecting
control.directive.emitted   RETRY     reason="retry budget remains"
lineage.generation.started            Gen 2 wondering
lineage.generation.failed             Gen 2 reflecting
control.directive.emitted   ABORT     reason="retry budget exhausted"

— exactly the shape from your comment. The directive events are now first-class citizens of the lineage aggregate, so a future DecisionTimelineProjector or the TUI lineage screen can consume them without knowing about a separate control bucket.

What this unlocks for later migrations

  • StepAction migration (your chosen first site) emits target_type="lineage" with generation_number and phase populated — projectors that already reconstruct lineages pick up the directives with zero changes.
  • execute_seed migration later emits target_type="execution"; execution-scoped projectors catch those.
  • Phase 3 AgentProcess emits target_type="agent_process" under the same event type.

No event-type churn across those transitions.

Test coverage

17 unit tests across four classes:

  • TestControlDirectiveEmittedCoreShape — event type, target aggregation mirror, payload target duplication, StrEnum serialization, terminality, emitter + reason
  • TestOptionalCorrelationFields — session/execution/lineage/generation/phase/context_snapshot/extra each appearing iff provided
  • TestTargetTypeIsForwardCompatibleagent_process and session target types
  • TestControlDirectiveCoversEveryDirective — every Directive member round-trips

Verification:

uv run ruff check src/ouroboros/events/control.py tests/unit/events/test_control_events.py
uv run ruff format --check <same>
uv run pytest tests/unit/events/ tests/unit/core/ -q   # 350 passed

Diff: +262 / -103 (net +159 over the previous commit).

Ready for another look.

Copy link
Copy Markdown
Contributor

@ouroboros-agent ouroboros-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — ouroboros-agent[bot]

Verdict: APPROVE

Reviewing commit ae8cb8c for PR #478

Review record: 77da3f58-4142-4b80-a13e-2dcf233a24fe

Blocking Findings

No in-scope blocking findings remained after policy filtering.

Non-blocking Suggestions

None.

Design Notes

The PR is coherent: Directive is a small, explicit control vocabulary, and control.directive.emitted is modeled as an observational event aggregated onto the target object so existing aggregate replays can naturally include decision history. I did not find a diff-scoped contract break or runtime issue in the introduced enum, event factory, or their tests.

Recovery Notes

First recoverable review artifact generated from codex analysis log.


Reviewed by ouroboros-agent[bot] via Codex deep analysis

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant