Skip to content

feat(agent): Reaction Protocol + dismiss_event_in_reaction operator slice (Slice A)#22

Merged
xmap merged 2 commits into
mainfrom
worktree-slice-a-reaction-split
Jun 2, 2026
Merged

feat(agent): Reaction Protocol + dismiss_event_in_reaction operator slice (Slice A)#22
xmap merged 2 commits into
mainfrom
worktree-slice-a-reaction-split

Conversation

@xmap
Copy link
Copy Markdown
Owner

@xmap xmap commented Jun 2, 2026

Summary

Two-commit slice closing the highest-severity finding from a recent cross-BC architecture critique: the Subscriber Protocol is operationally a saga framework wearing a projection's face, but the type system did not say so.

Commit 1 (foundation, +570/-73): Adds Reaction Protocol as the public sibling to Projection. Both satisfy Subscriber structurally; the operational split is fast batch (Projection, default 100) vs slow batch (Reaction, declares its own, typically 1). Worker reads per-subscriber batch_size via getattr so the 14 existing Projections need no migration. RunDebrieferSubscriber and CautionDrafterSubscriber classified as Reactions with batch_size = 1; the docstring's design intent (claimed-but-not-enforced) is now type-system enforced.

Commit 2 (slice, +2095/-2): Adds dismiss_event_in_reaction operator slice. When a Reaction's bookmark wedges on a poison event, the slice atomically advances the projection_bookmarks row past it AND records the dismissal as a DecisionRegistered (context = ReactionDismissal, choice = EventDismissed) carrying the operator's reason and the cursor moved past. Mirrors the forget_actor cross-store pattern (non-event SQL write inside the same conn.transaction() as the event append). Replaces the pre-slice on-call playbook of "SSH into prod, open psql, hand-edit projection_bookmarks.last_position."

29 tests across 6 files: 11 decider unit + 5 PBT (Hypothesis) for the validation cascade, 2 handler unit for authz-deny and in-memory-mode branches, 6 endpoint contract + 2 MCP tool contract, 5 PG integration for happy path + 3 error mappings + atomicity.

Test plan

  • make lint clean
  • make typecheck 0 errors
  • Unit + architecture suites: 20,026 pass / 577 skip
  • Projection-worker + agent-subscriber integration suites (24 tests against real PG): all pass
  • Pre-commit hooks all green (ruff + pyright + tach + arch fitness)

Out of scope (deferred per documented triggers)

  • Separate ReactionWorker with dedicated pool budget: deferred to 3rd Reaction OR first wedged-bookmark incident
  • Promoting ProjectionRegistrySubscriberRegistry: pure cosmetics, rename ripples across 16 BCs

🤖 Generated with Claude Code

xmap and others added 2 commits June 2, 2026 10:18
…h per-subscriber batch_size

The projection worker hardcoded batch_size=100 and treated all
Subscribers identically, even though RunDebriefer and CautionDrafter
take 5-15 s per apply (LLM round-trip). The run_debriefer docstring
claimed batch_size=1 but the worker ignored it; one pool connection
could be held for 100 * 15s = 25 minutes.

Add Reaction Protocol as the public sibling to Projection. Both
satisfy Subscriber structurally; the operational split is fast batch
(Projection, default 100) vs slow batch (Reaction, declares its own,
typically 1). Worker reads per-subscriber batch_size via getattr so
the 14 existing Projections need no migration.

Classify RunDebrieferSubscriber and CautionDrafterSubscriber as
Reactions with batch_size = 1. The docstring's design intent is now
enforced by the type system.

Future widening (separate ReactionWorker with its own pool budget,
operator escape-hatch for wedged bookmarks) stays deferred behind
named triggers: 3rd Reaction OR first wedged-bookmark incident.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When a Reaction's bookmark wedges on a poison event, the only
recovery before this slice was SSH into prod, open psql, hand-edit
projection_bookmarks.last_position. No audit, no review, no chance
to add a reason. The 3am playbook was hostile.

New slice: POST /agent/reactions/{name}/dismiss-event +
DismissEventInReaction MCP tool. Operator supplies subscriber_name,
event_id, and a free-form reason. Handler:

  1. Authorizes via deps.authz (denied -> 403)
  2. Refuses in-memory mode (no projection_bookmarks table) -> 503
  3. Acquires pool conn, begins TX, locks the bookmark row FOR UPDATE
  4. Loads the target event by event_id from the events table
  5. Pure decider validates: reason non-empty (400), event strictly
     after current bookmark in lexicographic (transaction_id,
     position) order (409 on rewind)
  6. Atomic same-TX writes: UPDATE projection_bookmarks past the
     dismissed event + EventStore.append_streams a DecisionRegistered
     (context=ReactionDismissal, choice=EventDismissed) carrying the
     reason and the cursor moved past in inputs

Mirrors the forget_actor cross-store pattern (non-event SQL write
inside the same conn.transaction() as the event append). Decision
audit reuses existing aggregate, no new event_type. ReactionDismissal
context + EventDismissed choice Literal added additively to Decision
state.py per the open-choice convention.

29 tests across 6 files: 11 decider unit + 5 PBT (Hypothesis) pinning
the validation cascade, 2 handler unit for authz-deny and
in-memory-mode branches, 6 endpoint contract + 2 MCP tool contract,
5 PG integration covering happy path + 3 error mappings + atomicity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 2, 2026

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/agent
  _subscribers.py
  errors.py
  routes.py
  tools.py
  wire.py
  apps/api/src/cora/agent/features
  __init__.py
  apps/api/src/cora/agent/features/dismiss_event_in_reaction
  __init__.py
  command.py
  decider.py
  handler.py
  route.py 153
  tool.py 74
  apps/api/src/cora/agent/subscribers
  caution_drafter.py
  run_debriefer.py
  apps/api/src/cora/decision/aggregates/decision
  state.py
  apps/api/src/cora/federation
  _subscribers.py
  apps/api/src/cora/infrastructure/projection
  __init__.py
  handler.py
  registry.py
  worker.py
  apps/api/src/cora/trust/features/register_visit
  decider.py
  handler.py
Project Total  

This report was generated by python-coverage-comment-action

@xmap xmap merged commit 160f3d4 into main Jun 2, 2026
3 of 4 checks passed
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