RIFF Two-Node Release Channels
Date: 2026-07-07
Status: Design proposal with Phase 1 implemented
Scope: M3 development/candidate node, M1 stable/fallback node, phone traffic routing, drain/undrain, promotion, rollback, and node comparison.
Core Idea
RIFF should treat M1 and M3 as two release channels, not just two copies of the same process.
- M3 is candidate: the fast development machine where new code lands first, gets restarted often, and is used for test calls, simulator runs, and canary traffic.
- M1 is stable: the fallback node on the last known-good build. It keeps answering production traffic while M3 is being changed, tested, or rolled back.
- Promotion is explicit: after M3 proves healthy, M1 is updated to the same commit and both nodes can rejoin as stable.
- Comparison is intentional: calls and simulations should be routed to a specific node or replayed on both nodes. Random active-active routing across different builds is a debugging trap.
The deployment question should never be "is M1 drained?" alone. The better question is "which node is stable, which node is candidate, and which traffic is allowed to reach each one?"
Amazon-Style Business Tenets
These tenets are written in the style of strong operating principles: customer backwards, mechanism-driven, and explicit about tradeoffs.
-
Never miss a real call for the sake of a deploy. A caller should always have at least one healthy stable node available before a candidate node is restarted or tested.
-
Make the blast radius visible and small. A candidate build should receive only canary traffic until it passes defined checks. The default path for unknown callers stays stable.
-
Use mechanisms, not heroics. Drain state, release channel, git SHA, active-call count, static-audio freshness, and routing decisions should be machine-readable. Operators should not infer them from logs.
-
Single source of truth for promotion. A node is promoted by commit SHA and mode, not by "whatever is currently running." Promotion requires both code identity and health evidence.
-
Two-way doors stay reversible. Candidate deploys must be easy to roll back: drain M3 and leave M1 serving stable traffic. Promotion to both nodes is a higher bar.
-
Compare before debating. When M3 feels better or worse than M1, run the same scenario against both nodes and compare traces, latency, audio assets, route decisions, and final slots.
-
Operational truth beats intent. Health pages, call exports, and node status must show the node, mode, SHA, routing table version, and drain state. If the UI says both nodes are online, both must be able to accept the traffic they claim to accept.
-
Automate the boring checks.
.envsyntax, routing JSON, active calls, static-audio audit, and expected phone-number mappings are deployment gates, not manual memory.
Why This Is Good
This gives RIFF a safer, faster release motion:
- Safer live changes: M1 keeps production stable while M3 absorbs risky changes like LAW auth prompts, barge-in changes, or new flow compiler output.
- Faster debugging: a test call can be pinned to M3. If it fails, we know which code answered.
- Real A/B evidence: M1 and M3 can run the same simulator or replay payloads so latency, route quality, and audio behavior can be compared.
- Cleaner rollback: if M3 regresses, drain M3 and leave M1 alone.
- Better confidence before promotion: M1 updates only after M3 has proven the commit under test.
Use Cases
1. LAW Auth Prompt Change
Problem: a LAW prompt says "six-digit" while the SMS sends a four-digit code.
Desired workflow:
- M1 remains
stable. - M3 pulls the fix and becomes
candidate. - Caller ID
+15124317196or the LAW DID test window routes to M3. - Operator calls
512-277-7LAWand verifies the spoken prompt. - Static-audio audit confirms the LAW WAV matches the current text.
- Promote the commit to M1.
Why this is good: the bug can be tested live without exposing all callers to a candidate build.
2. Barge-In Latency Comparison
Problem: M3 feels faster than M1, or one node misses "0" while the other routes correctly.
Desired workflow:
- Run the same LAW or Space Channel scenario against M1 and M3.
- Compare time-to-first-audio, user-speech-to-route, static audio duration, DTMF/locked-choice decisions, and final state.
- Mark the finding as code, data, audio, or infrastructure.
Why this is good: the team stops arguing from memory and gets node-by-node evidence.
3. Space Channel Content Refresh
Problem: Space Channel audio is regenerated frequently and can dirty local working trees.
Desired workflow:
- M3 refreshes content and validates generated audio.
- M1 is not updated until the content build is committed or deliberately excluded.
- Promotion script stashes only approved local generated artifacts on M1 before pulling.
Why this is good: local generated files do not silently block deployment or get mixed into unrelated releases.
4. Emergency Rollback
Problem: a candidate build starts dropping calls or looping in the menu.
Desired workflow:
- Set M3
drained. - Confirm M1 is
stableand accepting calls. - Preserve M3 logs and call exports.
- Replay the failing call locally before another deploy attempt.
Why this is good: rollback is one command and the failed build stays available for diagnosis.
Design
Release Channels
Each node has a release channel:
| Channel | Meaning | Normal owner |
|---|---|---|
stable |
Last known-good build for production callers. | M1, then both nodes after promotion |
candidate |
New build under active validation. | M3 |
shadow |
Receives replay/compare work only; does not answer new live calls. | Either node |
drained |
Server may run, but node accepts no new tunnel traffic. | Either node |
The release channel is separate from process health. A node can be healthy but drained, or unhealthy while marked stable. Routing must check both.
Node Modes
Add a small persistent mode file on each node:
/tmp/riff-phone-node/MODE
Allowed values:
stable
candidate
shadow
drained
DRAINED can remain as a compatibility marker, but MODE becomes the richer
source of truth. For compatibility:
MODE=drainedcreates or honors/tmp/riff-phone-node/DRAINED.- Removing
DRAINEDshould not automatically meanstable; the node should explicitly transition tostableorcandidate.
Health Contract
/healthz should include:
{
"ok": true,
"node": "M3",
"role": "M3",
"mode": "candidate",
"release_channel": "candidate",
"git_sha": "ec34a636",
"branch": "main",
"dirty": false,
"active_calls": 0,
"drained": false,
"accepting_live_calls": true,
"routing_table_hash": "sha256:...",
"started_at": "2026-07-07T12:00:00Z"
}
The follow-along page should keep the current left/right node indicator, but it should show mode as well:
- left half: M3
- right half: M1
- green: stable and accepting traffic
- yellow: candidate
- gray: drained
- red: unhealthy
Routing Contract
Do not rely on Cloudflare random active-active behavior when nodes run different commits. Use one of these routing levels.
Level 1: Manual Drain
Current behavior:
- drain M1, all new calls go to M3;
- undrain M1, both can answer.
This is safe for simple testing but poor for comparison.
Level 2: Canary Caller IDs
Route known operator caller IDs to M3 candidate while everyone else remains on M1 stable.
Example policy:
{
"default_channel": "stable",
"canary_callers": {
"+15124317196": "candidate"
}
}
This needs a deterministic ingress/router layer or per-node webhook selection.
Level 3: Canary Dialed Numbers
Route a specific DID to candidate:
{
"+15122777529": {
"flow": "legal_intake",
"channel": "candidate"
}
}
Useful when testing a single vertical such as LAW.
Level 4: Explicit Test Token
Allow a temporary test flag in the incoming webhook or an admin-set call plan:
{
"next_call_from": "+15124317196",
"route_to_node": "M3",
"expires_at": "2026-07-07T12:15:00Z"
}
This is the cleanest operator experience: "my next call should go to M3."
Promotion State Machine
M1 stable @ old SHA
M3 candidate @ new SHA
|
| deploy candidate to M3
v
M1 stable @ old SHA
M3 candidate @ new SHA, healthy
|
| canary checks pass
v
M1 drained -> pull new SHA -> restart -> health check
|
| M1 healthy
v
M1 stable @ new SHA
M3 stable @ new SHA
Rollback:
M3 candidate fails
|
v
M3 drained
M1 stable continues serving
Commands
Add a single node-control command:
scripts/node-mode.sh M1 status
scripts/node-mode.sh M1 stable
scripts/node-mode.sh M1 candidate
scripts/node-mode.sh M1 shadow
scripts/node-mode.sh M1 drain
scripts/node-mode.sh M1 undrain
Add release workflow commands:
scripts/deploy-candidate.sh --node M3
scripts/compare-nodes.py --flow legal_intake --scenario law_auth_4digit
scripts/promote-stable.sh
scripts/rollback-candidate.sh --node M3
Keep scripts/deploy_rolling.sh, but change it to call these lower-level
commands instead of carrying all deployment logic inline.
Preflight Gates
Every deploy, drain, undrain, and promotion should run:
bash -n .env
python - <<'PY'
import json, os
json.loads(os.environ["RIFF_DIALED_ROUTING_TABLE_JSON"])
PY
git rev-parse HEAD
git status --short
curl /healthz
Additional routed-flow gates:
- static-audio audit for every routed flow;
- phone-number routing table check;
- active calls must be zero before forced restart;
- node mode must match the intended operation;
- M1 pull must stash or preserve local generated audio artifacts before
git pull --ff-only.
Compare-Nodes Output
A node comparison should produce a small report:
scenario: legal_intake/law_auth_4digit
candidate_sha: ec34a636
stable_sha: a5e63178
M1 stable:
time_to_first_audio_ms: 640
auth_prompt_text: four-digit
final_state: menu
result: pass
M3 candidate:
time_to_first_audio_ms: 590
auth_prompt_text: four-digit
final_state: menu
result: pass
delta:
M3 faster by 50 ms
no route divergence
no prompt divergence
For a live call, comparison can be done by replaying the captured caller turns through the other node in shadow mode.
Implemented First Cut
Commit 4c64d3b5 implemented the node-mode foundation. This is the current
operator contract that Claude, Codex, and human operators should use.
Built now:
scripts/node-mode.shmanages the persistent mode file/tmp/riff-phone-node/MODE.scripts/phone-node-start.shhonorsMODEand the legacy/tmp/riff-phone-node/DRAINEDmarker./healthzreportsmode,release_channel,git_sha,branch,dirty,active_calls,drained,accepting_live_calls,routing_table_hash, andstarted_at./api/phone/nodesand the voice-page node chip show stable, candidate, shadow, drained, and offline states..envshell syntax andRIFF_DIALED_ROUTING_TABLE_JSONJSON syntax are validated before phone-node restart.
Not built yet:
scripts/deploy-candidate.sh --node M3scripts/promote-stable.shscripts/rollback-candidate.sh- deterministic caller-ID canary routing
- simulator
--node M1|M3|bothrouting
The current system makes node state explicit and machine-readable. It does not yet make live Telnyx/Cloudflare traffic route by caller ID.
Agent Quickstart
Check local node status:
scripts/node-mode.sh local status
Check a named node, delegating through SSH when RIFF_M1_SSH or RIFF_M3_SSH
is configured:
scripts/node-mode.sh M3 status
scripts/node-mode.sh M1 status
Mark M3 as the candidate node:
scripts/node-mode.sh M3 candidate
Mark M1 as stable:
scripts/node-mode.sh M1 stable
Take a node out of live tunnel service but leave the server available for local debugging:
scripts/node-mode.sh M3 shadow
Drain a node completely from new tunnel traffic:
scripts/node-mode.sh M3 drain
Return a drained node to stable mode:
scripts/node-mode.sh M3 undrain
Compatibility:
scripts/phone-node-start.sh --drainstill createsDRAINEDand now writesMODE=drained.scripts/phone-node-start.sh --undrainclearsDRAINEDand writesMODE=stable.DRAINEDoverridesMODEin/healthzso old operational habits remain fail-safe.
Live-Traffic Semantics
| Mode | Server may run | Tunnel connector may run | accepting_live_calls |
Intended use |
|---|---|---|---|---|
stable |
yes | yes | true | production-safe traffic |
candidate |
yes | yes | true | explicit validation/canary traffic |
shadow |
yes | no | false | replay, compare, local debug |
drained |
yes | no | false | rollback, deploy, maintenance |
stable and candidate are the only modes where phone-node-start.sh --ensure
will start the Cloudflare tunnel connector. shadow and drained keep the
phone server reachable locally but do not rejoin the live tunnel.
Simulator Routing Guidance
The simulator should default to M3/candidate once node targeting is added, because M3 is the latest-code validation lane. The planned option shape is:
python -m riff.flow_eval --flow legal_intake --node M3
python -m riff.flow_eval --flow legal_intake --node M1
python -m riff.flow_eval --flow legal_intake --node both
Recommended semantics for the future implementation:
- default
--node candidate, resolving to M3 when M3 reportsmode=candidate; --node M3hits the M3 phone/service base URL directly;--node M1hits the M1 phone/service base URL directly;--node bothruns the same scenario against both nodes and stores a comparison artifact;- refuse ambiguous node selection when both nodes report the same mode but
different SHAs unless
--nodeis explicit.
Simulator routing should not depend on the public phone ingress. It should hit the selected node's base URL directly so validation does not disturb live callers.
Current Canary-Routing Gap
The system does not yet support "my caller ID goes to M3; everyone else goes to M1." Until a deterministic ingress/canary layer exists, live testing should use manual drain/shadow operations and direct simulator/replay targeting.
Implementation Plan
Phase 1: Safer Drain/Undrain
- Done: add
scripts/node-mode.sh. - Done: store node mode in
/tmp/riff-phone-node/MODE. - Done: update
scripts/phone-node-start.shto honorMODE. - Done: add
bash -n .envand routing JSON validation before restart. - Done: add
mode,git_sha,dirty, andaccepting_live_callsto/healthz.
Exit criteria:
node-mode.sh M1 statusreports node, mode, SHA, dirty state, active calls, drained state, and health.- A malformed
.envblocks restart before launch.
Phase 2: Candidate Deploy
- Add
scripts/deploy-candidate.sh --node M3. - Restart only M3.
- Keep M1 stable.
- Add canary route declaration, even if manual at first.
- Produce a deploy report with SHA, tests, static-audio audit, and health.
Exit criteria:
- M3 can run a candidate SHA while M1 remains stable.
- Operator can send their next test call to M3 intentionally.
Phase 3: Promotion
- Add
scripts/promote-stable.sh. - Pull exact candidate SHA on M1.
- Restart M1 while drained.
- Verify M1 health and route table.
- Set both nodes to
stable.
Exit criteria:
- M1 and M3 both report the same SHA and
mode=stable. - Promotion refuses if SHAs differ unexpectedly.
Phase 4: Node Comparison
- Add
scripts/compare-nodes.py. - Support simulator scenarios first.
- Add call replay against the non-answering node in shadow mode.
- Store comparison artifacts in
docs/flow-eval/node-compare/<date>/.
Exit criteria:
- LAW, Space Channel, and property-management scenarios can compare M1 vs M3.
- Reports include latency, route decisions, prompt text, final state, and slot deltas.
Open Questions
- Should canary routing live inside RIFF phone ingress or outside it at the tunnel/webhook layer?
- Should M1 ever run
candidate, or is candidate always M3 by convention? - Should
shadowbe a full running phone node or just a replay endpoint? - How do we expose "my next call goes to M3" in the operator UI?
- Should generated Space Channel content be promoted independently from code, or always committed as part of a release?
Recommended First Cut
Build the smallest mechanism that changes daily behavior:
- Done: add
node-mode.sh. - Done: add richer
/healthz. - Done: add
.envand routing JSON validation before restart. - Next: add
deploy-candidate.sh --node M3. - Next: add
promote-stable.sh.
Do not build a full ingress router first. Start with deterministic operator workflow and status visibility. Once the team trusts the mode model, add canary caller-ID routing and node comparison.