Skip to content

feat(reaction): promote cross-entity reactions to first-class app primitive (#128)#133

Merged
nerdsane merged 7 commits intomainfrom
feat/reactions-first-class-primitive
Apr 16, 2026
Merged

feat(reaction): promote cross-entity reactions to first-class app primitive (#128)#133
nerdsane merged 7 commits intomainfrom
feat/reactions-first-class-primitive

Conversation

@nerdsane
Copy link
Copy Markdown
Owner

Summary

Closes #128. Extends the existing ReactionDispatcher along four additive axes so apps can replace hand-written "WASM-as-plumbing" modules (e.g. katagami-curation's build_session_message) with declarative TOML.

What's new:

  • ReactionTarget.params_from — pipe source-entity field values into target action params at dispatch time. Static params + dynamic params_from merge via a shared helper; collisions are a parse-time error; missing source fields log and skip the key (reaction still fires with partial params).
  • TargetResolver::Create — fresh UUID per dispatch via sim_uuid() (DST-safe). Distinct from CreateIfMissing; needed for pipeline chaining where each source action spawns a brand-new target instance.
  • ReactionGuard — conditional firing with sync source-field variants (field_equals, field_in, bool_true/false, state_in), async cross-entity cross_entity_state_in (reuses ServerState::resolve_entity_status), and composite all_of / any_of / not bounded at MAX_GUARD_DEPTH = 4. Guard-skipped rules do NOT emit a ReactionResult.
  • Developer docs: docs/reactions.md (full TOML reference + three example patterns) and a pointer from AGENT_GUIDE.md §9.

What's not new (architectural reaffirmation, per ADR Sub-Decision 5):

  • Reactions stay separate from the action layer. Verification tractability, authorization scope, transactional boundary, and cascade bounds all depend on the separation.
  • All additions are additive with #[serde(default)]; paw-fs reactions and [[agent_trigger]]-synthesized rules remain byte-identical.

Also includes:

  • fix: os-apps/temper-fs/reactions/reactions.toml resolver types had been PascalCase ("CreateIfMissing", "Field") while the parser matched only snake_case — dead data since merge. Snake-case corrected + regression test added.

Commits (ADR → feature → docs → e2e)

b3e920a docs(adrs): 0045 reactions as first-class app primitive
88ace3f feat(reaction): params_from — pipe source fields into reaction params
84c33c8 feat(reaction): Create target resolver — fresh sim_uuid per dispatch
da29ebb feat(reaction): ReactionGuard — conditional firing, source + cross-entity
25a88a3 fix(temper-fs): snake_case resolver types in reactions.toml
7fd187b docs(reaction): developer guide for cross-entity reactions
5149e05 test(reaction): end-to-end verification through production dispatcher

Test plan

Local verification (all green):

  • cargo build --workspace
  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test -p temper-server --lib reaction:: — 51/51
  • cargo test -p temper-server --test reaction_cascade — 12/12 (sim dispatcher, plus paw-fs reactions.toml regression load through the parser, guard pass/skip, params_from cascade, Create determinism under SimReactionSystem)
  • cargo test -p temper-server --test reaction_e2e_prod — 4/4 production-path E2E (real async ReactionDispatcher via ServerState::dispatch_tenant_action; basic reaction, source-field guard selection, Not(state_in) skip, params_from missing-field tolerance)
  • cargo test -p temper-platform --test platform_e2e_dst — 6/6
  • DST review marker + code review marker written for every code-bearing commit

Not run locally — deferred to CI:

  • cargo test --workspace — the full workspace suite includes several heavyweight platform DST tests (dst_platform_cedar, dst_platform_random, etc.) that run stateright model-checking and individually take 5–10+ minutes. None of them touch the reaction layer. Attempts to run the suite locally blew past 30 min; pushed with --no-verify after Rita explicitly authorized falling back to the narrower verification. CI will run the full suite.

Verification posture:

  • paw-fs reactions.toml now load-bearing: parsed in a regression test that fails if the file drifts from the parser again.
  • [[agent_trigger]] synthesized rules unchanged — guard: None, params_from empty, verified by the existing tests.
  • Production ReactionDispatcher path (not just SimReactionSystem) covered end-to-end in reaction_e2e_prod.rs.

Create resolver is not yet exercised through the live production path — the prod_dispatcher_* tests would need target-actor-on-demand spawn infrastructure which is out of scope for this PR. Its determinism is proven under SimReactionSystem (create_resolver_is_deterministic_across_seeded_runs).

🤖 Generated with Claude Code

nerdsane and others added 7 commits April 16, 2026 14:25
ADR for temper#128. Commits architectural direction for extending
the existing ReactionDispatcher with four additive capabilities:
params_from (dynamic params from source fields), Create target
resolver (fresh sim_uuid), ReactionGuard (sync source-field + async
cross-entity + bounded composites), and docs/AGENTS.md amendment.

Sub-Decision 5 reaffirms reactions stay separate from the action
layer: verification tractability, authorization scope, transactional
boundary, and cascade bounds all depend on the separation. Track 4
extends reactions; it does not fuse them into actions.

All additions are additive with serde defaults; paw-fs reactions
and [[agent_trigger]] synthesis remain byte-identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of temper#128. Adds ReactionTarget.params_from: a BTreeMap of
target-param-name → source-field-name. At dispatch time, a new shared
helper build_effective_params merges the static params with values read
off the source entity's fields. Both ReactionDispatcher (prod, async)
and SimReactionSystem (deterministic sim) route through the helper, so
prod and sim apply identical merge semantics.

Semantics:
- Collision between params and params_from is a parse-time error.
- Missing source field at runtime logs a warning and skips the key;
  the reaction still fires with partial params.
- Non-object static params bypass the dynamic merge and log a warning.

paw-fs reactions.toml and [[agent_trigger]]-synthesized rules remain
byte-identical (params_from defaults to empty via #[serde(default)];
registry/relations.rs supplies BTreeMap::new() explicitly).

32 reaction lib tests + 9 reaction_cascade integration tests pass
(including two new integration tests: missing-source-field cascade,
TOML params_from round-trip through the registry).

DST compliance: BTreeMap for deterministic iteration, pure helper
with no I/O, no clock, no random, single shared implementation across
prod and sim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of temper#128. Adds TargetResolver::Create — a resolver variant
that returns a genuinely new entity ID on every reaction dispatch via
temper_runtime::scheduler::sim_uuid(). Distinct from CreateIfMissing
(keyed on a source field, intended for per-source-entity singletons).

Motivating use case: pipeline chaining where each source action spawns
a brand-new target entity instance — composed with params_from from
Phase 1, this retires the build_session_message WASM pattern from
katagami-curation: fresh target ID + fields piped from source, all in
TOML with zero code.

DST compliance:
- sim_uuid() (not uuid::Uuid::new_v4) — seeded DeterministicIdGen
  under simulation, Uuid::now_v7() in production.
- New test create_resolver_is_deterministic_across_seeded_runs asserts
  two identical install_deterministic_context(42) runs produce the
  same 3-ID sequence. SimReactionSystem cascades stay reproducible.
- New test create_resolver_returns_fresh_id_each_call asserts two
  consecutive calls within one seeded context produce distinct IDs.

paw-fs reactions and [[agent_trigger]] synthesis are unchanged
(neither uses Create). 34 reaction lib tests + 9 integration tests
pass. rustfmt clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tity

Phase 3 of temper#128. Adds `guard: Option<ReactionGuard>` to
ReactionTrigger. Reactions with a guard only fire when the guard
evaluates true; guard-skipped rules do NOT emit a ReactionResult
(they never fired).

ReactionGuard variants:
- Sync, source-only: FieldEquals, FieldIn, BoolTrue, BoolFalse, StateIn
  (StateIn matches source post-action status, complementing to_state).
- Async, cross-entity: CrossEntityStateIn { entity_type,
  entity_id_source, required_status } — mirrors the IOA variant from
  ADR-0015 so developers transfer knowledge.
- Composite: AllOf, AnyOf, Not. Bounded at MAX_GUARD_DEPTH = 4,
  validated at parse time (TigerStyle: budgets not limits).

Evaluation uses the same two-pass pattern as IOA guards
(state/dispatch/cross_entity.rs):
1. collect_cross_entity_queries walks the tree, emits queries with
   target IDs already read from source fields.
2. Caller resolves queries into a CrossStatusMap via the appropriate
   lookup primitive (prod: async state.resolve_entity_status — the
   existing helper, reused; sim: sync entity_to_actor + inner.status).
3. evaluate_with_resolved re-walks using source fields, source post-
   status, and the resolved map. Pure fn — no I/O, no clock, no random.

Strictness: cross-entity guards with a missing/empty target ID do NOT
collect a query and evaluate to false (stricter than IOA vacuous-truth
— for reactions, an empty target id is almost always a misconfiguration).

DST compliance:
- BTreeMap for CrossStatusMap; deterministic key ordering.
- No duplicated fetch logic between prod and sim; both converge on
  the same CrossEntityQuery::key() format.
- Sim path is fully sync; prod path awaits only the existing
  resolve_entity_status helper.

paw-fs reactions and [[agent_trigger]] synthesis unchanged (guard: None).
51 reaction lib tests + 11 integration tests pass (15 new unit tests
on guard evaluation, 5 new registry parse tests, 2 new cascade
integration tests — state_in gate + Not composite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing os-apps/temper-fs/reactions/reactions.toml used PascalCase
resolver types ("CreateIfMissing", "Field"), but parse_reactions only
matches snake_case ("create_if_missing", "field"). The file was
effectively dead data: the platform bootstrap path registers tenants
with `Vec::new()` reactions, so the mismatch was silent.

Renames the three resolver types to snake_case and adds a regression
test (paw_fs_reactions_toml_loads_through_parser) that includes the
file verbatim and asserts all three rules parse. Future edits to
reactions.toml now fail at test time if they drift from the parser.

No behavior change to any running system — the reactions were not being
loaded anywhere before this fix. This makes the ADR-0045 "paw-fs
regression posture intact" commitment load-bearing rather than vacuous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of temper#128. Adds docs/reactions.md — the full developer
reference for reactions: when to use them vs WASM integrations, the
TOML schema (when+guard, then+params+params_from, resolve_target for
all 5 variants), three example patterns (pipeline chain, session
callback, cleanup-on-failed), and the invariants that don't change
(fire-and-forget, MAX_REACTION_DEPTH=8, tenant isolation, system
principal, determinism).

Also amends docs/AGENT_GUIDE.md §9 with a short "two mechanisms"
preamble distinguishing reactions from WASM integrations. The plan
originally called for an AGENTS.md amendment; the repo has no
AGENTS.md — AGENT_GUIDE.md is the equivalent developer-facing guide,
so that's where the pointer lives.

No code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the ADR-0045 verification loop. The existing reaction_cascade
tests exercise SimReactionSystem (the deterministic sim path); this
new test file exercises the production ReactionDispatcher (async,
through ServerState.dispatch_tenant_action) so the full live stack is
verified:

    parse_reactions → try_register_tenant_with_reactions
      → build_reaction_registry → ReactionDispatcher
      → ServerState.dispatch_tenant_action → target entity transition

Four scenarios:
- prod_dispatcher_fires_basic_reaction: full stack wires up; Payment
  transitions to Authorized in response to Order.ConfirmOrder.
- prod_dispatcher_honours_source_field_guard: two rules, state_in
  guard selects exactly one; proves ReactionGuard evaluation through
  the prod path.
- prod_dispatcher_not_guard_skips_firing: Not(state_in) composite
  guard skips the rule; target stays in initial state — no stray
  side effect.
- prod_dispatcher_params_from_missing_field_still_fires: ADR
  "warn + skip the key" policy verified; reaction fires with partial
  params, target commits successfully.

Create resolver is not exercised here; it requires target actor
on-demand-spawn infra separate from this PR. Determinism of the
resolver itself is already proven in resolver.rs unit tests under
SimReactionSystem.

All four pass. cargo fmt clean, cargo clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nerdsane
Copy link
Copy Markdown
Owner Author

Live-server E2E verification (added after initial push)

Started temper serve locally, registered a fresh tenant with reactions via the real HTTP API, and fired OData actions against the running server. Every Phase 1–3 feature exercised through the full stack: HTTP handler → ServerState.dispatch_tenant_actionReactionDispatcher → target entity actor.

Setup

  • cargo build --bin temper (debug)
  • temper serve --port 3789 --tenant e2e --storage turso --no-observe
  • POST /api/specs/load-dir with two entities (Order, Payment) and a reactions.toml exercising params_from + Not(state_in) guard.

Reactions registered (real file loaded through the new parser):

[[reaction]]
name = "order_confirmed_authorizes_payment"
[reaction.when]
entity_type = "Order"
action = "ConfirmOrder"
to_state = "Confirmed"
[reaction.then]
entity_type = "Payment"
action = "AuthorizePayment"
params = { origin = "e2e" }
params_from = { piped_field = "nonexistent_source_field" }
[reaction.resolve_target]
type = "same_id"

[[reaction]]
name = "skipped_when_confirmed"
[reaction.when]
entity_type = "Order"
action = "ConfirmOrder"
[reaction.when.guard]
type = "not"
[reaction.when.guard.guard]
type = "state_in"
values = ["Confirmed"]
[reaction.then]
entity_type = "Payment"
action = "FailPayment"
[reaction.resolve_target]
type = "same_id"

Spec verification L0–L3 all passed for both entities.

Flow

POST /tdata/Orders                             → Order:o1 created (Draft)
POST /tdata/Orders('o1')/Reactions.E2E.AddItem      → items=1
POST /tdata/Orders('o1')/Reactions.E2E.SubmitOrder  → Submitted
POST /tdata/Orders('o1')/Reactions.E2E.ConfirmOrder → Confirmed
GET  /tdata/Payments('o1')                          → status=Authorized

Server log evidence (from the live run):

Dispatching reaction rule="order_confirmed_authorizes_payment"
  source_entity=Order source_id=o1
  target_entity=Payment target_id=o1 target_action=AuthorizePayment depth=0

params_from source field missing on source entity;
  skipping key (reaction still fires with partial params)
  rule="order_confirmed_authorizes_payment"
  target_key="piped_field" source_field="nonexistent_source_field"

trajectory.entry entity_type=Payment action=AuthorizePayment
  success=true from_status=Some("Pending") to_status=Some("Authorized")

reaction guard failed; skipping rule
  rule="skipped_when_confirmed" cross_entity_queries=0

Final OData state (GET /tdata/Payments('o1'))

  • status: "Authorized" (reaction fired)
  • fields.origin: "e2e" (static param delivered through the cascade)
  • events: Created (reaction auto-spawned the actor) → AuthorizePayment(params={"origin":"e2e"})

Proved live:

  1. parse_reactions accepts the new TOML schema via POST /api/specs/load-dir
  2. ReactionDispatcher fires through async production path, auto-spawns missing target actors, dispatches the target action
  3. params_from merges static + dynamic params; missing source field logs warn and skips the key (reaction still fires with partial params)
  4. ReactionGuard::Not(StateIn) correctly skipped the second rule — Payment landed in Authorized, not Failed
  5. Trajectory and OData view show the target entity in the expected state, with the piped static params

Rita asked for the real-server verification gap to be closed. Closed.

@nerdsane nerdsane merged commit ae7d2f6 into main Apr 16, 2026
2 of 3 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.

feat: promote cross-entity reactions to first-class app primitive

1 participant