Skip to content

fix(actor-runtime): execute or reject the full Effect vocabulary (ARN-179) - #370

Draft
nerdsane wants to merge 3 commits into
mainfrom
claude/arn-179-effect-vocabulary
Draft

fix(actor-runtime): execute or reject the full Effect vocabulary (ARN-179)#370
nerdsane wants to merge 3 commits into
mainfrom
claude/arn-179-effect-vocabulary

Conversation

@nerdsane

@nerdsane nerdsane commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Fixes ARN-179 ([BUG] Postgres actor-runtime backend silently drops half the Effect vocabulary).

Defect

SpecDrivenActor::apply_effect matched 8 of the 16 temper_jit::table::Effect variants and dropped the other 8 through a _ => tracing::debug! catch-all: ListAppend, ListRemoveAt, IncrementCounterByParam, DecrementCounterByParam, SetCounterFromParam, ScheduleAction, ScheduleAtAction, SpawnEntity. A spec whose transition appends to a list or sets a counter from an action param had that effect silently discarded, so a later guard reading the variable (list_length_min, counter_min) evaluated stale state and mis-gated transitions.

A second, disagreeing copy of the vocabulary decision lived in temper-cli/src/serve/actor_runtime.rs (spec-level effect match) — one instance of the ARN-212 parallel-interpreter drift called out in the issue's enrichment comment.

Fix (root cause)

Single source of truth in the crate, enforced at construction, exhaustive at compile time:

  • validate_effect_support (new, temper-actor-runtime/src/spec_actor.rs): exhaustive match over the compiled TransitionTable's effects, called from from_automaton (now Result) and from_ioa. A new Effect variant in temper-jit now fails compilation here instead of being silently dropped.
  • Implemented the five param-driven state effects (ListAppend, ListRemoveAt, IncrementCounterByParam, DecrementCounterByParam, SetCounterFromParam), mirroring the canonical executor semantics in temper-server/src/entity_actor/effects.rs (list value keyed by var name, removal index from {var}_index, deltas accept numbers/numeric strings and default to 0, set_counter_from_param requires a non-negative integer).
  • Rejected at construction: ScheduleAction/ScheduleAtAction/SpawnEntity (the runtime has no delayed delivery and no per-entity addressing) and Custom trigger effects with no reaction routing (previously a warn-level runtime no-op). apply_effect has no catch-all; construction-rejected variants fail the activation loudly if ever reached.
  • Deleted the duplicated CLI effect-vocabulary check; the CLI keeps its serve-specific integration/action-trigger checks.
  • ADR-0156 documents the vocabulary policy and the deliberate scoping relative to ARN-212.

TDD

  • RED commit 9ddfa464 adds 4 failing regression tests (committed alone; failures documented in the commit message): param effects dropped, list_length_min mis-gating after list_append, schedule/schedule_at/spawn accepted at construction, unrouted trigger accepted at construction.
  • GREEN commit makes all 4 pass with the fix above.

DST note: temper-actor-runtime and temper-cli are not simulation-visible crates (no DST suite applies); the exhaustive-match + construction-rejection is the regression barrier for this class.

Provenance note

This crate is the externally-contributed PG actor runtime (ARN-26 / PR #218). The issue itself says "coordinate before changing" — this PR is an arena submission per the ARN-165 non-security queue; routing the merge decision through the contributor remains available to the judge/Rita.

Verification

  • cargo test -p temper-actor-runtime --lib — 6/6 (4 new regression tests green)
  • cargo test -p temper-cli — 72/72
  • cargo fmt --check, git diff --check, cargo clippy -p temper-actor-runtime -p temper-cli -- -D warnings — clean
  • Full workspace suite + live local E2E: evidence posted as PR comments below.

Greptile Summary

This PR fixes ARN-179, where SpecDrivenActor::apply_effect silently dropped 8 of the 16 Effect variants via a _ => debug! catch-all, causing list/counter state mutations from param-driven effects to be discarded and guards reading those variables to evaluate stale state.

  • New validate_effect_support (exhaustive match over the compiled TransitionTable) is called from from_automaton (now Result) at construction time, making SpecDrivenActor the single source of truth for the Postgres actor runtime's effect vocabulary — a new Effect variant now fails compilation here instead of being silently dropped.
  • Implemented ListAppend, ListRemoveAt, IncrementCounterByParam, DecrementCounterByParam, and SetCounterFromParam, mirroring the canonical executor in temper-server/src/entity_actor/effects.rs line-for-line; ScheduleAction, ScheduleAtAction, and SpawnEntity are explicitly rejected at construction with actionable errors.
  • Deleted the duplicate effect-vocabulary check from the CLI's validate_actor_runtime_compatible; the CLI retains its integration and action-trigger checks. Four regression tests (RED → GREEN commit sequence) cover each fixed scenario.

Confidence Score: 5/5

Safe to merge. The fix is mechanically straightforward, the new code was verified against the canonical executor line-by-line, and four targeted regression tests confirm both the bug and the fix.

Every changed path is well-covered: exhaustive matches replace the silent catch-all, construction-time rejection replaces runtime drops, and param-driven semantics were confirmed to match the canonical executor in temper-server. No regressions introduced in the CLI; the removed validation block was superseded by the runtime crate's own construction check, which now fires earlier (at serve startup) via the from_ioa call in configure_postgres_actor_runtime.

No files require special attention. effects.rs is the core new file and its logic is a faithful mirror of temper-server/src/entity_actor/effects.rs.

Important Files Changed

Filename Overview
crates/temper-actor-runtime/src/spec_actor/effects.rs New file implementing the full Effect vocabulary: exhaustive validate_effect_support (construction-time rejection) and exhaustive apply_effect (no catch-all). Param-driven effects correctly mirror the canonical executor in temper-server/src/entity_actor/effects.rs line-for-line.
crates/temper-actor-runtime/src/spec_actor/mod.rs Renamed from spec_actor.rs to mod.rs (split into submodules). from_automaton changed from infallible to Result; validate_effect_support wired at construction; params extraction refactored to always produce a serde_json::Value (Null when absent), enabling apply_effect to receive params on every call.
crates/temper-actor-runtime/src/spec_actor/tests.rs Four new regression tests covering: param-driven effect mutations, list guard mis-gating (the exact ARN-179 scenario), construction rejection of schedule/spawn effects, and construction rejection of unrouted trigger effects. Tests are specific and well-named.
crates/temper-cli/src/serve/actor_runtime.rs Removes the duplicate effect-vocabulary check from validate_actor_runtime_compatible; retains integration and action-trigger checks. SpecDrivenActor::from_ioa is now the sole vocabulary gate, called with an empty routing map during actor registration at serve startup.
docs/adrs/0156-pg-actor-runtime-effect-vocabulary.md ADR-0156 documents the vocabulary policy, construction-rejection decision, and deliberate scoping relative to ARN-212. Well-structured and covers alternatives considered.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant CLI as temper-cli
    participant VAC as validate_actor_runtime_compatible
    participant SDA as SpecDrivenActor::from_ioa
    participant VES as validate_effect_support
    participant AE as apply_effect

    CLI->>VAC: collect_actor_runtime_definitions(registry, types)
    VAC-->>CLI: Err if legacy integrations or action triggers

    CLI->>SDA: from_ioa(ioa_source, HashMap::new())
    SDA->>VES: validate_effect_support(table, routing)
    Note over VES: Exhaustive match over all Effect variants
    VES-->>SDA: Err if Schedule/Spawn/unrouted Custom
    SDA-->>CLI: Ok(SpecDrivenActor) or Err

    CLI->>CLI: system.register(actor)

    Note over CLI,AE: At runtime, for each incoming message:
    CLI->>AE: apply_effect(state, effect, params, ctx)
    Note over AE: Exhaustive match — no catch-all
    AE-->>CLI: Ok(()) or ActorError::HandlerFailed
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant CLI as temper-cli
    participant VAC as validate_actor_runtime_compatible
    participant SDA as SpecDrivenActor::from_ioa
    participant VES as validate_effect_support
    participant AE as apply_effect

    CLI->>VAC: collect_actor_runtime_definitions(registry, types)
    VAC-->>CLI: Err if legacy integrations or action triggers

    CLI->>SDA: from_ioa(ioa_source, HashMap::new())
    SDA->>VES: validate_effect_support(table, routing)
    Note over VES: Exhaustive match over all Effect variants
    VES-->>SDA: Err if Schedule/Spawn/unrouted Custom
    SDA-->>CLI: Ok(SpecDrivenActor) or Err

    CLI->>CLI: system.register(actor)

    Note over CLI,AE: At runtime, for each incoming message:
    CLI->>AE: apply_effect(state, effect, params, ctx)
    Note over AE: Exhaustive match — no catch-all
    AE-->>CLI: Ok(()) or ActorError::HandlerFailed
Loading

Reviews (1): Last reviewed commit: "refactor(actor-runtime): split spec_acto..." | Re-trigger Greptile

nerdsane and others added 3 commits July 12, 2026 10:49
…ants (ARN-179)

RED: SpecDrivenActor::apply_effect silently drops list_append,
list_remove_at, increment/decrement-by-param, and set_counter_from_param
through a catch-all arm, so a list_length_min guard mis-gates after a
list_append transition; schedule/schedule_at/spawn and unrouted trigger
effects are accepted at construction and then dropped at runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-179)

GREEN: SpecDrivenActor construction now validates the compiled table via
validate_effect_support — an exhaustive match, so new Effect variants
fail compilation instead of silently dropping. Implements the five
param-driven state effects with canonical-executor semantics; rejects
schedule/schedule_at/spawn and unrouted triggers at construction; the
duplicated CLI-level effect vocabulary check is deleted (single source
of truth in the crate). ADR-0156 records the policy.

Behavioral note: the params handling in handle() now skips merging when
incoming params decode to JSON null; previously a null payload wiped the
receiver's accumulated fields. This aligns with the documented
preserve-context intent and was confirmed as an improvement in review.

from_automaton is now fallible; in-repo callers all go through from_ioa,
whose signature is unchanged. Out-of-repo direct callers of
from_automaton are source-broken by design (construction must validate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
spec_actor.rs reached 899 lines after the ARN-179 fix; the repo limit is
500. Content-preserving split: mod.rs (actor, messages, state, routing),
effects.rs (effect vocabulary: validation + application), tests.rs. The
public path spec_actor::validate_effect_support is re-exported unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nerdsane

Copy link
Copy Markdown
Owner Author

Live local E2E evidence (ARN-179)

Setup: native PostgreSQL 17.8 on localhost:5432; spec dir with Inventory entity exercising every param-driven effect plus a list_length_min guard (inventory.ioa.toml + model.csdl.xml); one binary built at merge-base a28fdb2e ("before"), one at the PR head ("after").

BEFORE (main @ a28fdb2) — capability rejected at startup

$ DATABASE_URL="postgres://seshendranalla@localhost:5432/temper_arn179_before" TEMPER_EVENT_STORE=postgres \
    ./temper-before serve --port 3178 --storage postgres --actor-runtime postgres \
    --actor-backed-type Inventory --specs-dir ./e2e-specs --tenant arn179 --no-observe
Error: tenant arn179 entity Inventory action AddTag uses effect ListAppend { var: "tags" }, which is not yet supported by --actor-runtime postgres
EXIT CODE: 1

(The silent-drop path itself sits behind this CLI wall on main; the RED unit tests in commit 9ddfa464 prove the crate-level drop directly: list_append leaves the list empty and the list_length_min guard then mis-gates Lock.)

AFTER (PR head) — spec accepted, every effect executes, guard gates correctly

Server starts, Inventory passes L0–L3 verification, PG actor runtime scheduler starts:

  [verify] Inventory: [PASS] L0 Symbolic PASSED: 6 guards satisfiable, 1 invariants inductive, 0 unreachable
  [verify] Inventory: [PASS] L1 Model Check PASSED: 9 states explored, all properties hold
  [verify] Inventory: [PASS] L2 Simulation PASSED: 5 seeds, 146 transitions, 0 dropped msgs
  [verify] Inventory: [PASS] L3 Property Tests PASSED: 100 cases, 30 max steps
{"message":"actor scheduler starting","target":"temper_actor_runtime::scheduler"}
Listening on http://0.0.0.0:3179

Live flow (each POST returns the updated actor state):

$ curl -s -X POST -H 'X-Tenant-Id: arn179' -H 'Content-Type: application/json' -d '{"Id":"inv-1"}' http://localhost:3179/tdata/Inventorys
{"@odata.type":"#Inventory","Id":"inv-1","namespace":"arn179/inv-1"}

$ curl -s -X POST ... -d '{"tags":"urgent"}' ".../Inventorys('inv-1')/Temper.InventoryDemo.AddTag"
{"entity_type":"Inventory","entity_id":"inv-1","status":"Active","counters":{"progress":0,"total":0},"booleans":{},"lists":{"tags":["urgent"]},"fields":{"Id":"inv-1","tags":"urgent"}}

$ curl -s -X POST ... -d '{"qty":5}'  .../Temper.InventoryDemo.RecordBatch   → counters: {'progress': 0, 'total': 5}
$ curl -s -X POST ... -d '{"qty":2}'  .../Temper.InventoryDemo.ConsumeBatch  → counters: {'progress': 0, 'total': 3}
$ curl -s -X POST ... -d '{"value":42}' .../Temper.InventoryDemo.SetProgress → counters: {'progress': 42, 'total': 3}
$ curl -s -X POST ... -d '{}' .../Temper.InventoryDemo.Lock                  → status: Locked | lists: {'tags': ['urgent']}

$ curl -s -H 'X-Tenant-Id: arn179' "http://localhost:3179/tdata/Inventorys('inv-1')"
{"entity_type":"Inventory","entity_id":"inv-1","status":"Locked","counters":{"progress":42,"total":3},"booleans":{},"lists":{"tags":["urgent"]},"fields":{"Id":"inv-1","tags":"urgent","qty":2,"value":42},"@odata.context":"$metadata#Inventorys/$entity","@odata.id":"Inventorys('inv-1')"}

The exact mis-gating scenario from the issue — a list_length_min guard reading a list a prior transition appended to — now gates correctly (Lock succeeds only because list_append really appended).

AFTER — unexecutable effects now fail fast at startup with an actionable error

$ ./temper-after serve ... --specs-dir ./e2e-specs-schedule ...   # same spec but with a schedule effect
Error: failed to build actor for Inventory: action "Lock" uses effect type "schedule", which the Postgres actor runtime cannot execute (it has no delayed delivery or per-entity spawning)
EXIT CODE: 1

@nerdsane

Copy link
Copy Markdown
Owner Author

Independent reviewer (Claude Fable 5, dedicated session) — ARN-179 / PR #370

I reviewed this PR with no prior context, from the GitHub diff, the commit history, the E2E evidence comment, and the code at the PR head. I verified the TDD claims and the canonical-semantics mirroring against the running tree.

What I checked and confirmed

  • Root cause is fixed correctly, not patched. The silent _ => tracing::debug! catch-all in SpecDrivenActor::apply_effect is gone. Both validate_effect_support (construction) and apply_effect now match all 16 temper_jit::table::Effect variants exhaustively with no catch-all (crates/temper-actor-runtime/src/spec_actor/effects.rs:29-77 and :96-186). I confirmed the enum has exactly 16 variants (crates/temper-jit/src/table/types.rs), so a new variant fails compilation in this crate — that is the durable regression barrier for this class, and it is the right one.
  • Construction-time rejection is wired into serve and propagates. from_automaton/from_ioa now return Result and are called at serve time (crates/temper-cli/src/serve/actor_runtime.rs:88), so schedule/schedule_at/spawn specs fail fast with an actionable error rather than mis-executing — matches the E2E evidence.
  • Canonical semantics faithfully mirrored. I diffed the new ListAppend, ListRemoveAt, IncrementCounterByParam, DecrementCounterByParam, SetCounterFromParam, and counter_delta_from_params against crates/temper-server/src/entity_actor/effects.rs:478-544, 660-668. They match line-for-line (the only canonical-only bit is the item_count special-casing, which SpecActorState does not have — it keeps items in the counters map, so it stays internally consistent).
  • Duplicate vocabulary removed, single source of truth. The disagreeing effect-vocabulary match in temper-cli is deleted; the retained validate_actor_runtime_compatible keeps only its integration/action-trigger serve checks (crates/temper-cli/src/serve/actor_runtime.rs:254-273). This removes one of the two ARN-212 drifting copies, correctly scoped.
  • TDD is auditable and real. RED commit 9ddfa464 is committed alone (only the 267-line test addition). I checked that commit out and ran it: the 4 regression tests fail as genuine assertion failures against the parent behavior (list_append leaves the list empty, list_length_min mis-gates Lock to Active, schedule/spawn and unrouted trigger accepted at construction) — not compile errors. At the head the same suite is 6/6 green.
  • ADR-0156 is accurate and correctly scoped relative to ARN-212 (docs/adrs/0156-...md); number is unique. git diff --check clean; no ADR collision.

Observation (non-blocking, no action required)

The serve path constructs actors with an empty routing map (from_ioa(&definition.ioa_source, HashMap::new()), pre-existing), so the routed-Custom-accept branch of validate_effect_support is not reachable via CLI serve — any trigger effect spec is rejected there (either by the retained action.triggers check or by empty-routing construction). That is consistent with reactions being unsupported at serve today and is not a regression: the old CLI effect loop also rejected trigger effects. The routing-aware branch is forward-looking and correct; flagging only so the scoping is on the record.

Verification I ran

  • cargo test -p temper-actor-runtime --lib at RED 9ddfa464: 4 failing (assertion failures, as documented); at head: 6/6 green.
  • Line-by-line compare of param-effect semantics vs temper-server canonical executor: identical.
  • git diff --check: clean; ADR number unique.

No findings at any severity. I would ship this.

Verdict: PASS

@nerdsane

Copy link
Copy Markdown
Owner Author

@greptile review

@nerdsane

Copy link
Copy Markdown
Owner Author

ARENA SHIPPABLE · Claude Code (Fable 5) · 2026-07-12 13:29 PDT

Receipts:

Notes for the judge: Linear MCP token expired this session — ARENA START (10:28 PDT) is recorded on the master status board (M94) and in this PR instead of on ARN-179; will backfill when Linear reconnects. This crate is the externally-contributed PG actor runtime (ARN-26) — merge routing through the contributor remains available.

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