Skip to content

Commit 5bfa6f8

Browse files
johnteeeclaude
andcommitted
feat: ADR 0032 M7 — event-spine wiring guard + realized-architecture doc; MIGRATION COMPLETE
Closes the ADR 0032 migration. M7's original goal ("ContextBus + webhook consume the spine; delete inline eventing") was assessed and NOT done — it is a regression or vacuous: - Webhook is an AuditLogger sink (audit.add_sink), already transitively spine-fed via the M1 spine->audit consumer. A direct spine consumer would see only the spine-emitted subset = coverage regression. - ContextBus and the integration RunEventStream are unwired in production. - Inline audit.record calls ARE the complete event record (read by evidence/ receipts/webhook), not redundant eventing to delete. Done instead (owner chose guard + document): - scripts/validate_event_spine_wiring.py: enforces the realized invariant — (A) taxonomy closure (every RunEventType maps losslessly to the audit record); (B) no orphaned event bus (AST scan for high-signal lifecycle-event methods must match a curated allowlist; generic publish/emit excluded to avoid noise). - tests/test_event_spine_wiring.py: clean-repo + seeded-bad-fixture coverage. - check-event-spine-wiring pre-commit hook. - ADR 0032: "Realized architecture (M1-M7)" section records the converged outcome and the lesson — the spine's value is the typed read side + a single typed lifecycle path, not relocating runtime-stateful enforcement. The plan gate was the one gate that genuinely belonged on the spine. - Plan §7 M7 row + tasks T001/T002 CLOSED unsuitable, T003 DONE, T004 CLOSED. Constraint: guard is read-only static analysis; no runtime/behavior change; allowlist names every sanctioned event-delivery surface so new competing buses fail the gate. Tested: tests/test_event_spine_wiring.py 7 + lifecycle spine 22 + validate_wiring 6 = 35 passed; validator exits 0 on clean repo and flags seeded orphan bus + unmapped event. Not-tested: full suite not run on 3.12 (hypothesis missing in 3.14 sandbox). Confidence: high Roadmap-Status: unchanged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8b280bb commit 5bfa6f8

6 files changed

Lines changed: 377 additions & 7 deletions

File tree

.pre-commit-config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ repos:
1111
entry: python3 scripts/check_circular_imports.py
1212
language: system
1313
pass_filenames: false
14+
- id: check-event-spine-wiring
15+
name: check-event-spine-wiring
16+
# ADR 0032 M7 invariant: one typed lifecycle path (EventSpine -> audit
17+
# consumer), no orphaned/competing event bus, every RunEventType mapped
18+
# to the audit record. Read-only static check.
19+
entry: python3 scripts/validate_event_spine_wiring.py
20+
language: system
21+
pass_filenames: false
1422
- id: ruff-format
1523
name: ruff-format
1624
entry: env UV_CACHE_DIR=.uv-cache uv run ruff format

docs/adr/0032-run-event-taxonomy.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,62 @@ New code lives inside the existing `teaagent/runner/` package (teaagent/runner/_
122122
| M5 | HookRegistry re-homed onto spine; public hook API documented | Existing hook tests pass via aliases |
123123
| M6 | ContextBus + webhook sinks consume spine; inline emission paths deleted | No orphaned eventing modules |
124124

125+
### Realized architecture (M1–M7, completed 2026-06-13)
126+
127+
The phases above were the *proposal*. Executed parity-first and assessed against
128+
the code at each step, the migration converged on a **narrower, sound** outcome —
129+
recorded here as the durable contract (see the per-phase work-logs under
130+
`docs/work-log/` and the plan `docs/plans/adr-0032-m1-m6-work-plan-2026-06-13.md`):
131+
132+
- **M1** — done as proposed: `AuditLogger` is an `EventSpine` consumer
133+
(`register_audit_consumer`), byte-equivalent on the proof scenario.
134+
- **M2** — re-scoped to **taxonomy-only**: every evidence/decision audit event is
135+
typed in `RunEventType` and mapped both directions; the reader surfaces them
136+
from the audit JSONL. (Emit-site migration deferred.)
137+
- **M3****plan gate moved to an interceptor** (the one gate that fit), landed
138+
shadow→enforce in two commits with a reason-code parity oracle.
139+
- **M4****approval and budget enforcement STAY INLINE.** Both are
140+
runtime-stateful (approval: live JIT/session state + handler + auto-mode-
141+
swappable policy; budget: mutable dedup sets + an interactive `on_prompt`
142+
side-effect handler + multi-point evolving-cost checks). Forcing them into a
143+
pure interceptor-on-event was a regression risk invisible to unit parity tests.
144+
Their observability still reaches the fold via typed audit events.
145+
- **M5****hook OBSERVABILITY** is typed onto the spine; **hook EXECUTION stays
146+
in the tool-dispatch layer** (`teaagent/tools.py`) because PreToolUse/PostToolUse
147+
mutate in-flight args/results, and the session-lifecycle hooks are unwired.
148+
- **M6****evidence/receipts fold over the typed stream** (`build_evidence_from_events`,
149+
now the production path inside `build_run_evidence_bundle`). Fixed a real
150+
lossiness gap (typed `RunEvent` now carries `created_at`).
151+
- **M7****guard + document (this section).** "ContextBus + webhook consume the
152+
spine; delete inline eventing" was **not** done: it is a regression or vacuous.
153+
154+
**The realized invariant (M7), enforced by `scripts/validate_event_spine_wiring.py`
155+
and `tests/test_event_spine_wiring.py`:**
156+
157+
```
158+
EventSpine.emit ──(register_audit_consumer, M1)──▶ AuditLogger.record
159+
160+
add_sink fan-out: webhook, OTel, …
161+
```
162+
163+
- `EventSpine` (`teaagent/runner/_events.py`) is **the** typed run-lifecycle path.
164+
- `AuditLogger` (`teaagent/audit.py`) is the **complete event record and the sink
165+
hub**, and a spine consumer since M1. Governance events that stay inline
166+
(approval, budget, hooks) are written via `audit.record` — that **is** the
167+
record, not redundant inline eventing to delete.
168+
- **Webhook / OTel are audit *sinks***, not direct spine consumers — a direct
169+
consumer would see only the spine-emitted subset (a coverage regression).
170+
- `ContextBus` and the integration `RunEventStream` are **unwired in production**;
171+
they are not lifecycle buses. The guard's allowlist names every sanctioned
172+
event-delivery surface, so a **new competing lifecycle-event bus fails the gate**
173+
and forces a conscious decision. The taxonomy-closure check proves no
174+
`RunEventType` is orphaned from the audit record.
175+
176+
**Lesson:** the spine's realized value is the **typed read side** (evidence →
177+
receipts) and a single typed lifecycle path — not wholesale relocation of
178+
runtime-stateful enforcement. The plan gate was the one gate that genuinely
179+
belonged on the spine.
180+
125181
## Consequences
126182

127183
**Positive:**

docs/generated/docs-inventory.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Do not edit this file manually — regenerate instead.
4242
| `adr/0029-consensus-validation-deferred.md` | working | 1587 | `8a2da40abc07` |
4343
| `adr/0030-root-module-freeze.md` | working | 1297 | `bee25422e85f` |
4444
| `adr/0031-shadow-mode-exit-criteria.md` | working | 3598 | `46a9a0d5eaac` |
45-
| `adr/0032-run-event-taxonomy.md` | working | 10046 | `d2cc47767526` |
45+
| `adr/0032-run-event-taxonomy.md` | working | 13534 | `dcae5044f4c4` |
4646
| `adr/README.md` | working | 7109 | `713a782f5411` |
4747
| `agent-contribution-contract.md` | constitution | 5204 | `9c2dad1195d2` |
4848
| `agent-mode-operator-guide.md` | working | 2778 | `25b258ab7bfe` |
@@ -414,7 +414,7 @@ Do not edit this file manually — regenerate instead.
414414
| `ops/security-hardening.md` | working | 11733 | `0a385c7dab82` |
415415
| `ops/troubleshooting.md` | working | 9127 | `4921b6d50f5c` |
416416
| `permission-and-approval-playbook.md` | working | 6560 | `813bc74bb156` |
417-
| `plans/adr-0032-m1-m6-work-plan-2026-06-13.md` | archive | 57704 | `53c78129190f` |
417+
| `plans/adr-0032-m1-m6-work-plan-2026-06-13.md` | archive | 60885 | `70eeb1c1049b` |
418418
| `plans/agent-ecosystem-acceptance-roadmap-2026-05-31.md` | archive | 29099 | `7c4a4972cfeb` |
419419
| `plans/community-pain-points-response-plan-2026-06-05.md` | archive | 7276 | `571d010133ad` |
420420
| `plans/competitive-positioning-plan-2026-05-31.md` | archive | 8726 | `d16dfd2bdd99` |

docs/plans/adr-0032-m1-m6-work-plan-2026-06-13.md

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ consumers by M6.
179179
| ADR-0032-M4 (CLOSED — owner decisions B + B-analog, 2026-06-13) | **No gate moves to an interceptor; approval AND budget enforcement both STAY INLINE.** Both proved runtime-stateful on assessment, a poor fit for the pure-interceptor model. **Approval** (decision B): live JIT/session state, tool handler, auto-mode-swappable policy — every coupling gap was invisible to a unit parity test (`docs/work-log/m4-approval-sliceB-blocked-2026-06-13.md`). **Budget** (decision B-analog): it is three mechanisms — only the global cost cap (`_assert_cost_budget`) is stateless; the phase budget (live `phase_tracker`) and the warning ladder (`_budget_warning_levels_emitted` + `BudgetMonitor._emitted_levels`/`_prompted` dedup sets + an interactive `on_prompt` side-effect handler — the same `assert_allowed` shadow-coexistence trap that blocked approval) are stateful, and even the cost cap is enforced at two evolving-cost points per iteration that do not map 1:1 to events (`docs/work-log/m4-budget-stays-inline-2026-06-13.md`). Both gates' observability is already provided by M2 (their audit events — `tool_call_*`, `approval_*`, `budget_warning`, `budget_prompt`, `phase_budget_warning` — are typed + reader-surfaced); the M6 fold reads them without owning enforcement. Approval/budget behavior unchanged. **Net: plan gate (M3) is the sole governance gate moved to an interceptor.** |
180180
| ADR-0032-M5 (REVISED — observability-only, 2026-06-13) | **Hook OBSERVABILITY folds onto the spine; hook EXECUTION stays in the tool-dispatch layer.** Assessment found the planned "HookRegistry on spine" unsuitable for the same runtime-coupling reason as approval/budget: PreToolUse/PostToolUse run in `teaagent/tools.py::execute` and **mutate in-flight `arguments`/`result`** (the spine has no channel to ferry mutated payloads back to the dispatch site), and the 6 session-lifecycle hooks (SessionStart/End, UserPromptSubmit, PreCompact, Stop, SubagentStop) have **no production caller** — nothing to strangle; wiring them is feature work. Done: the 5 dispatch-layer hook audit events (`tool_hook_pre_mutation`, `tool_hook_pre_mutation_blocked`, `tool_hook_vetoed`, `tool_hook_post_mutation`, `tool_hook_post_failed`) are typed in `RunEventType` + mapped both directions, so the M2-T001 reader surfaces hook veto/mutation activity from the audit JSONL for the M6 fold. Mapping/reader only; audit bytes unchanged; hook execution + mutation semantics unchanged. See `docs/work-log/m5-hooks-observability-only-2026-06-13.md`. |
181181
| ADR-0032-M6 (was M2 fold; corrected scope A) — **COMPLETE (FOLD-T001 + T002)** | Evidence and receipts are folded from the typed event stream and equal the legacy builder on success/failure/pending fixtures (cancelled once emitted in M2); the fold reads the full stream (no fallback flag, per Q1). **FOLD-T001**: `build_evidence_from_events()` parallel builder sharing `_assemble_evidence_bundle` with the legacy path (cannot drift; only the event *source* differs), parity-asserted (`tests/test_run_evidence.py::test_m6_fold_*`). Fixed a structural gap: the typed `RunEvent` was lossy — dropped top-level `created_at` (threaded into command/test/approval timestamps); added optional `RunEvent.created_at`, reader populates it. **FOLD-T002 (cutover DONE)**: `build_run_evidence_bundle` now routes production evidence THROUGH the typed reader + fold — the typed stream is the production path; the raw-dict assembly survives only as the shared helper (so the two cannot diverge). Suite-wide green (evidence/receipt/summary/5-min-proof/first-hour/adversarial + all bundle consumers, ~218 tests). **Finding: no synthetic receipt-only fixtures existed to retire** — the receipt/evidence path was already event-backed (`test_run_receipt.py` writes real RunStore events; `test_real_run_receipt_completeness_from_plan` validates a real run); direct `RunEvidenceBundle(...)` constructions are legitimate downstream-consumer/checker unit tests, not masking fixtures. The plan anticipated a gap that does not exist. Parity test re-anchored against `_assemble_evidence_bundle` (the raw-dict path) so it stays meaningful post-cutover. |
182-
| ADR-0032-M7 (was M6) | ContextBus and webhook sinks consume the spine; inline emission paths are deleted; validator shows no orphaned eventing modules. |
182+
| ADR-0032-M7 (was M6) — **COMPLETE as guard + document, 2026-06-13** | Original goal ("ContextBus + webhook consume the spine; delete inline eventing") **NOT done — it is a regression or vacuous.** Webhook is an `audit.add_sink` already fed transitively by the M1 spine→audit consumer; a *direct* spine consumer would see only the spine-emitted subset (coverage regression). ContextBus + integration `RunEventStream` are **unwired in production** (no callers) — nothing to migrate. The inline `audit.record` calls are the **complete event record** (read by evidence/receipts/webhook), not redundant eventing to delete. **Done instead (owner: guard + document):** `scripts/validate_event_spine_wiring.py` + `tests/test_event_spine_wiring.py` enforce the realized invariant — one typed lifecycle path (EventSpine→audit consumer), an allowlist of sanctioned event-delivery surfaces so a NEW competing lifecycle bus fails the gate, and taxonomy closure (no RunEventType orphaned from the audit record). Added as a pre-commit hook. ADR 0032 "Realized architecture (M1–M7)" section documents the outcome. **MIGRATION COMPLETE.** |
183183

184184
## 8. Task Plan
185185

@@ -655,7 +655,15 @@ commit once Slice A is green.
655655
- Parallelizable: yes after T002.
656656
- Human Review Required: yes for API wording.
657657

658-
### ADR32-M6-T001: ContextBus Consumer
658+
> **M7 TASKS (mislabeled ADR32-M6-T001..T004 — these are the M7 ContextBus/
659+
> webhook/validator/cleanup work per the §5 re-sequencing) — RESOLVED 2026-06-13.**
660+
> T001 (ContextBus consumer) and T002 (webhook consumer) CLOSED as unsuitable
661+
> (ContextBus unwired; webhook is an audit sink already spine-fed — direct
662+
> consumption regresses coverage). **T003 (orphaned-eventing validator) DONE.**
663+
> T004 (delete inline emission) CLOSED — inline `audit.record` IS the complete
664+
> record, not redundant. See the §7 M7 row and ADR 0032 "Realized architecture".
665+
666+
### ADR32-M6-T001: ContextBus Consumer [CLOSED — unsuitable]
659667

660668
- Goal: make ContextBus consume RunEvents or derive DeltaCards from RunEvents.
661669
- Scope: context deltas only; do not add async queues.
@@ -681,7 +689,13 @@ commit once Slice A is green.
681689
- Parallelizable: yes after M5.
682690
- Human Review Required: yes for model-visible boundary.
683691

684-
### ADR32-M6-T002: Webhook Sink Consumer
692+
### ADR32-M6-T002: Webhook Sink Consumer [CLOSED — unsuitable]
693+
694+
> **CLOSED (2026-06-13).** `WebhookAuditSink` is an `AuditLogger` sink
695+
> (`audit.add_sink`); since M1 the audit logger is a spine consumer, so webhook
696+
> is already transitively spine-fed. Making it a *direct* spine consumer would
697+
> regress it to only the spine-emitted subset (most governance events are written
698+
> via direct `audit.record`). Webhook correctly stays an audit sink.
685699
686700
- Goal: route webhook audit delivery through the event spine.
687701
- Scope: webhook delivery, HMAC, filtering, failure suppression.
@@ -709,7 +723,17 @@ commit once Slice A is green.
709723
- Parallelizable: yes with M6-T001 after M5.
710724
- Human Review Required: yes if webhook payload schema changes.
711725

712-
### ADR32-M6-T003: Orphaned Eventing Validator
726+
### ADR32-M6-T003: Orphaned Eventing Validator [DONE]
727+
728+
> **DONE (2026-06-13).** `scripts/validate_event_spine_wiring.py` +
729+
> `tests/test_event_spine_wiring.py`. Two checks: (A) taxonomy closure — every
730+
> `RunEventType` maps losslessly to the audit record (no orphaned typed event);
731+
> (B) no orphaned event bus — an AST scan for high-signal lifecycle-event methods
732+
> (`register_consumer`/`register_interceptor`/`add_sink`/`on_event`/
733+
> `publish_delta`/`subscribe_deltas`; generic `publish`/`emit` excluded to avoid
734+
> noise) must match a curated allowlist of sanctioned surfaces, so a new
735+
> competing bus fails. Seeded-bad-fixture tests included. Added as the
736+
> `check-event-spine-wiring` pre-commit hook.
713737
714738
- Goal: prove there are no competing lifecycle event systems left after M6.
715739
- Scope: static validation over audit strings, HookRegistry emissions,
@@ -734,7 +758,15 @@ commit once Slice A is green.
734758
- Parallelizable: yes late M6.
735759
- Human Review Required: no.
736760

737-
### ADR32-M6-T004: Remove Inline Emission Paths
761+
### ADR32-M6-T004: Remove Inline Emission Paths [CLOSED — nothing redundant to remove]
762+
763+
> **CLOSED (2026-06-13).** There are no redundant inline emission paths to
764+
> delete. The inline `audit.record` calls (approval, budget, hooks — everything
765+
> that stayed inline by the M4/M5 findings) ARE the complete event record that
766+
> evidence/receipts/webhook read; they are not duplicated by the spine (the
767+
> spine emits only the runner's lifecycle subset, which the audit consumer
768+
> records). Deleting them would lose events. The realized invariant is guarded
769+
> by T003 instead of by deletion.
738770
739771
- Goal: delete or quarantine old inline event emission paths after consumers
740772
own audit/hooks/context/webhook behavior.

0 commit comments

Comments
 (0)