ServicesDirector

Decisions and Delivery

A decision is the Director's answer to "how hard should I push right now?". It is computed on demand - when you GET /decision or report signals with respond: true - from three inputs: the decayed intensity estimate, the target curve at the session's current cycle position, and what budget remains.

The rules

With gap = target - current and band the policy's tolerance band:

  • trigger_climax - phase is peak, the estimate is at or above peak_intensity - band, the peak cooldown (min_seconds_between_peaks) has elapsed, and event budget remains. Costs 1 event budget and stamps last_peak_at.
  • spawn_wave - phase is build_up, the estimate is more than one band below the curve, and spawn budget remains. size (1-5) scales with how far below the curve the session is, jittered by the deterministic draw and capped by remaining budget; it costs size spawn budget.
  • ease_off - phase is relax and the estimate is more than one band above the curve. Free; carries ease_off_min_seconds as its suggested cooldown. sawtooth_relax and constant curves have no relax segment, so they never emit it - relief comes from the estimate decay instead.
  • hold - everything else. Note the phase gate: a session running hot during build_up holds (there is no ease-off outside the relax phase), and its rationale still reads "within tolerance band; holding".

A live spawn decision, captured while the estimate had decayed well below a rising curve:

{
  "phase": "build_up",
  "current_intensity": 0.27636334177129984,
  "target_intensity": 0.8180766666666666,
  "action": { "type": "spawn_wave", "size": 5, "cost": 5, "cooldown_seconds": 12.0 },
  "rationale": "below target curve in build-up phase; spawn budget available",
  "seed_draw": 0.5300190292209616,
  "decision_index": 2
}

Polling is not a pure read

Every decision read advances the session's decision_index and spends budget when a costed action fires. Two back-to-back polls of the session above both spawned: budgets went 12 -> 7 -> 3 (verified). Poll on your game loop's cadence, not per frame - and treat the returned cooldown_seconds as the earliest you should act again. Budgets replenish when the session crosses into a new cycle: after this session's 180-second cycle rolled over, budgets_remaining.spawn was back to 12 (verified).

session get and curve are pure reads; only decision computation mutates the session.

Determinism and replay

Each decision's seed_draw is a deterministic function of the session seed and decision_index. Two sessions started with --seed 7 produced the identical draw 0.9096310281224071 at index 0 (verified). With deterministic.per_session_seed on (the default) an omitted seed is derived from the session id, so re-running the same policy, seed, and signal timeline reproduces the same decision sequence - and GET /sessions/{id}/curve returns the full decision timeline to replay against. Note that timing still matters: the curve target moves with elapsed time, so replays are exact only when signals and reads land at the same offsets.

Push delivery

Instead of only polling, provision push delivery of every computed decision (each session holds one subscription; subscribing again replaces it):

snug director subscribe --session-id docs4-sess-a \
  --channel pubsub --target u.5JY5XUIVDLNWX5WV.director.decisions
  • pubsub - target is a channel name. Channel authorization applies: subscribe within your own u.{namespace}. prefix (mint a capability token to learn it) or a listen on the channel is rejected with a 403 handshake - see PubSub. Verified end-to-end: a snug pubsub listen on the channel above received the exact decision JSON the moment a poll computed it.
  • webhook - target must be an http(s) URL (anything else is 400 invalid_target, verified); the Director registers a webhook for you and returns its id in the subscription. A replaced webhook subscription retires the old webhook.
  • websocket - target is an existing WebSocket connection id; decisions arrive as pub/sub-style frames on that connection (frame format in the API reference).

Delivery is best-effort by design: a failed push is logged and never fails the decision itself, so the pacing loop keeps running when a downstream sink is unavailable. The decision returned to the caller and the one pushed are the same object - subscribers received decision_index 4 exactly as the poller saw it (verified).

On this page