Commit bc38734 implemented 13 money-safety guardrails in emit.rs (the live order-placing binary). Covers critical data-model fixes, runtime safety gates, and data-model changes to types::BookTop and observe::Observation.

Blockers Fixed

condition_id Must Have 0x-Prefix

The Gamma API can return a null conditionId field. Previously the code silently fell back to the market slug when the field was absent, which is wrong — a slug is not a valid CLOB condition ID. The fix: require the 0x-prefix explicitly; a missing or non-0x conditionId is now a hard error that skips the window.

YES Token Selected by Outcome Label, Not Array Index

Previously the YES token was assumed to be clobTokenIds[0]. The Gamma API returns outcomes as a JSON string (not a parsed array) that is parallel to clobTokenIds. The fix parses the outcomes string, does a case-insensitive match for "up" or "yes", and uses the corresponding index into clobTokenIds. Additionally, token_id is now validated as a parseable u128 before use.

fired_windows and total_fires Hydrated from DB on Startup

Both state variables are now populated by running SELECT window_id FROM emitted_signals on startup. Before this fix, a process restart would silently allow re-firing windows that had already been emitted, and total_fires would reset to zero. The fix makes the emitter restart-safe.

High-Severity Fixes

Book Staleness Gate

A new guard skips emission if the Polymarket book observation is more than EMIT_BOOK_MAX_STALE_SECS (5 seconds) old. This required adding pm_book_as_of: Option<DateTime<Utc>> to observe::Observation, which is threaded from BookTop.as_of.

Zero / NaN Ask Guard

An explicit check !pm_ask_f64.is_finite() || pm_ask_f64 <= 0.0 now guards the ask price before any arithmetic. Previously a zero or NaN ask could silently corrupt max_price.

NATS Publish Error Now Skips Before Recording

The previous code path would call fired_windows.insert(window_id) (and write to the DB) regardless of whether the NATS publish succeeded. The fix uses continue on a publish error, so a window is only marked as fired after a confirmed successful publish.

Medium-Severity Fixes

valid_until_ts Freshness Check

The emitter now verifies that valid_until_ts >= now + 10s before firing. Windows with stale or already-expired valid_until_ts are skipped rather than emitting an immediately-invalid directive.

Gamma HTTP Client Timeout

The reqwest::Client used for Gamma API calls now has a 5-second timeout set via reqwest::Client::builder().timeout(Duration::from_secs(5)). Without this, a hung Gamma request could block the emit loop indefinitely.

Calibration p Clamped to [0, 1]

The calibration bin index calculation now clamps p to [0.0, 1.0] before computing the bin. An out-of-range p value previously could cause an out-of-bounds index panic.

Market Window-Match via open_ts

The market-to-observation match now verifies that market.open_ts.timestamp() equals obs.window_id. BookTop carries window_id: Option<String> (set by poll_book); this is threaded into Observation as pm_book_window_id: Option<String>.

Low-Severity Fixes

calibration.json Loaded via include_str!

calibration.json is now embedded at compile time via include_str!("../../calibration.json"), eliminating the CWD dependency. The CAL_PATH environment variable still overrides this when set.

emitted_signals INSERT Column Binding Bug

The INSERT was binding yes_token_id to both the token_id and yes_token_id columns (double-binding bug). token_id and size are now obsolete — they are dropped by migration 0005.

Binance Staleness Tracking

last_binance changed from Option<f64> to Option<(f64, DateTime<Utc>)> to carry the timestamp alongside the value. This enables a 30-second Binance staleness gate: if the last Binance tick is >30s old, the window is skipped.

Data Model Changes

Structural Changes — Read Before Touching BookTop or Observation

These fields were added in this commit. Code that constructs BookTop or Observation manually (e.g., in tests) must supply them.

types::BookTop

New field: window_id: Option<String>

Set by poll_book to match the currently active window. Enables the market/observation window-match guard in the emitter.

observe::Observation

Three new fields added:

FieldTypePurpose
pm_book_as_ofOption<DateTime<Utc>>Timestamp of the book snapshot; used for the 5s book-staleness gate
pm_book_window_idOption<String>Threaded from BookTop.window_id; used for the market window-match guard
last_binance_tsOption<DateTime<Utc>>Timestamp of last Binance tick; used for the 30s Binance staleness gate

Migration 0005

migrations/0005_emitted_signals_v3.sql drops the now-obsolete token_id and size columns from crypto_shortterm.emitted_signals. These were removed from the Directive struct in the schema alignment at commit c12c830 (see pmv2 Directive Schema Alignment (c12c830)) but not yet dropped from the table.

Files Changed

FileChange
crates/types/src/lib.rsBookTop gains pub window_id: Option<String>
crates/ingest/src/polymarket.rsOutcome-label token selection, condition_id guard, HTTP timeout, poll_book stamps window_id onto BookTop, 3 new unit tests
crates/observe/src/lib.rsObservation gains pm_book_as_of, pm_book_window_id, last_binance_ts; EngineState::last_binance is Option<(f64, DateTime<Utc>)>; 7 new unit tests
crates/observe/src/calibration.rsfrom_json constructor, calibrate clamps p
crates/observe/src/bin/emit.rsFull rewrite with all guards
migrations/0005_emitted_signals_v3.sqlDrops token_id and size columns

Test Results

All passing after changes: 15 ingest tests (3 ignored/live-only), 14 fairvalue tests, 3 market_state tests, all observe tests including 7 new guard unit tests. Build, clippy -D warnings, and fmt clean.

BLOCKER-3 is the most dangerous restart trap

Before these changes, every process restart would re-fire the entire history of already-emitted windows. The DB hydration on startup is the fix — do not remove it.

BLOCKER-3 corollary — TRUNCATE alone won't clear the cap

The hydration is one-shot at boot. If you need to reset total_fires (e.g. clearing a Pattern A → B cutover residue where pre-cutover rows are pinning the new emitter against EMIT_MAX_FIRES), TRUNCATE-ing crypto_shortterm.emitted_signals is necessary but NOT sufficient — the in-memory counter keeps its boot value, and the emitter keeps logging EMIT_MAX_FIRES reached, signal emission paused at ~1Hz. You must bounce the container (sudo systemctl restart apps-shortterm-crypto-algo-emit.service) so the next boot re-hydrates against the now-empty table. The boot log line INFO emit: guardrail hydrated from DB hydrated=0 confirms the reset. Surfaced 2026-06-25 — see crypto-emit-max-fires-cap-clear-2026-06-25 for the full session and the corresponding [[gcp-terraform-ansible-gotchas|gotcha #38]].

DB insert vs NATS failure semantics

DB insert failure after successful NATS publish = safe (NATS Msg-Id dedup prevents double-execution on retry). NATS publish failure = the window must NOT be marked fired — continue enforces this.

Guard Summary Table

#SeverityGuardSkip Condition
1Blockercondition_id prefixMissing or non-0x conditionId from Gamma
2BlockerYES token by labelNo outcome matching “up”/“yes” in Gamma outcomes array
3Blockerfired_windows hydration(startup — prevents re-fire after restart)
4HighBook stalenesspm_book_as_of > EMIT_BOOK_MAX_STALE_SECS (5s)
5HighZero/NaN askpm_ask not finite or 0
6HighNATS error skips before recordPublish failure continue before fired_windows.insert
7Mediumvalid_until freshnessvalid_until_ts < now + 10s
8MediumGamma HTTP timeout5s timeout on Gamma API client
9MediumCalibration p clampp outside [0.0, 1.0]
10MediumMarket window-matchmarket.open_ts != obs.window_id
11Lowinclude_str! for calibration(no CWD dependency; CAL_PATH still overrides)
12LowINSERT column bindingDouble-bind of yes_token_id fixed; migration 0005 drops stale columns
13LowBinance stalenessLast Binance tick >30s old