MSO Cloud · Documentation

From signal to decision

Source: docs/architecture/signal-to-decision.md Updated 2026-09-21
On this page

How one measured number becomes a ranked line in a morning brief, then a recorded decision, then a row a person can dismiss, and finally a weight that changes tomorrow's ranking. This is C4 Level 3 for the signal half of the decision engine; the loop it feeds is in The Decision Engine, and the grade each part has reached is in Maturity Model.

Two things are called a signal#

The platform holds two different objects under that word, and merging them would break replay.

Object What one row is Where it lives
Feature store The scalar VALUE a rule predicate reads at a period signals, signal_values
Fired-instance ledger One EVALUATED BINDING at one as-of day: what fired, what was withheld, why signal_events

A rule reads the feature store to decide. The ledger records that a decision point happened at all, including the cases where nothing was said. The rule that keeps them apart is §117 in .claude/rules/mso-decision.md, which also states the replay contract: an episode pins the registry version AND the binding version, because a replay pinning one cannot reproduce the other.

The chain#

flowchart TB
  R[("agg_org_dataset_period<br/>rollup")] --> C["Collect<br/>bind pack signals, read 12 periods"]
  C --> E["Evaluate<br/>fired · insufficient · quiet"]
  E --> S["Suppress<br/>dismissed · cooldown · inhibited"]
  S --> K["Rank<br/>impact x severity x weight"]
  K --> B["Morning brief<br/>ordered legs"]
  S --> L[("signal_events<br/>append-only ledger")]
  L --> W["Reduce<br/>signal_stats weight_bp"]
  W -->|"multiplier into the next rank"| K
  B --> H["A person dismisses<br/>or marks acted"]
  H --> L
  K -.->|"columns exist, writers pass NULL"| D[("decision_episodes")]

The dotted edge is the honest part: decision_episodes carries domain, signal_event_id and signal_binding_version, and only domain is ever written with a value today. The link from a signal instance to the decision it produced is plumbed and unpopulated.

Collect#

collectOrgDatasetSignals (packages/metrics/src/dataset-signal-collect.ts) reads the signal declarations off the organization's INSTALLED dataset pack row, newest pack version winning, and binds each one at that pack version. The series it evaluates is agg_org_dataset_period at the dataset-wide total slice, twelve periods, capped at four thousand cells. A compensation-sensitive field is dropped by the read-scope resolver before any cell is fetched, so a salary column cannot reach a brief by way of a signal.

Two modules in packages/metrics now read that rollup for the brief, deliberately sharing one implementation of the scope and cell-picking rules:

  • dataset-brief-facts.ts reads the FACT behind a declared brief leg: the anchor month's total cell and the month before it. It owns resolveDatasetReadScope and pickLatestDatasetCell.
  • dataset-signal-collect.ts reads the SERIES behind a signal binding, importing both helpers rather than restating them.

A brief leg declares exactly one address: a dataset plus a measure key, or a catalog measure reference. briefLegSchema refuses a leg that carries both or neither (packages/metrics/src/brief-binding.ts). Eight leg kinds are declared and the list is append-only; stock_level, service_rate and defect_rate joined it with the warehouse pack.

Evaluate, then suppress#

Evaluation is three-outcome — fired, insufficient, quiet — and the collector adds unreadable when no series exists for the measure or the declared parameters do not satisfy the type. Suppression then runs over the ledger's own history. Six reasons are storable and a CHECK constraint holds the set:

Reason Written when
unreadable No series for the measure, or parameters that the signal type cannot accept
insufficient Too few periods to support the comparison the type makes
quiet The comparison ran and nothing crossed the threshold
dismissed A person dismissed this signal key inside the suppression window
cooldown The same key fired recently enough that repeating it would be noise
inhibited A higher-ranked instance in the same pass covers it; suppressed_by names it

Order matters and is fixed in collectDomainSignals: unreadable, then the evaluator's own outcome, then dismissed BEFORE cooldown, then inhibited. A dismissal is checked first so the recorded reason names the human rather than the clock. SIGNAL_DISMISS_SUPPRESSION_DAYS = 30 is the one place the window is stated (packages/metrics/src/signal-binding.ts); the SQL predicate derives from that constant.

Nothing is dropped. A withheld instance is written with its reason, which is what makes "why did the brief not tell me" answerable after the fact.

The ledger#

signal_events is append-only at the database. A BEFORE UPDATE trigger built from packages/schema/sql/append-only-guards.sql raises SQLSTATE 42501 on any mutation outside four feedback columns: dismissed_at, dismissed_by_user_id, dismiss_reason, acted_at. The TypeScript mirror of that set is SIGNAL_EVENT_FEEDBACK_COLUMNS.

The write path is worker-only and enforced by privilege, not convention. Runtime ACL (packages/schema/src/runtime-acl.ts) grants the worker identity SELECT and INSERT; the web identity gets SELECT and UPDATE and no INSERT. A web request therefore cannot manufacture a signal instance, and the only UPDATE it can land is one of the four feedback columns the trigger permits.

The table carries brand_id, so its row-level-security cohort is brand_partitioned_tables, and its idempotency key is UNIQUE NULLS NOT DISTINCT (org_id, brand_id, idempotency_key) — the NULL-brand row must conflict with itself or a re-run would duplicate it.

Rank, and the weight that corrects it#

rankScoreOf scores an instance from its impact in basis points, the signal declaration's own weight and a severity factor, then multiplies by a per-key multiplier in basis points. The multiplier comes from signal_stats, a REBUILDABLE cache over the ledger — never a source of truth.

weight_bp is a hand-written apprentice rule, stated in packages/metrics/src/signal-stats.ts and bounded in both places it could drift:

  • One or more acted_at rows in the ninety-day feedback window: 10000, which is a multiplier of one.
  • Otherwise two or more dismissals in that window: 5000.
  • Otherwise 10000.
  • Clamped to 2500..10000 in TypeScript, with a CHECK ("weight_bp" BETWEEN 2500 AND 10000) behind it.

So the correction can only demote, and only to half. It is not a learned policy, it carries no propensity and no confidence interval, and it moves no rung on the evidence ladder. rebuildSignalStatsForOrg upserts on the natural key and then deletes stale rows BY PRIMARY KEY rather than by organization, because the table is brand-partitioned and an org-wide delete would take another brand's row with it.

The record cycle and the decision cycle are separately gated#

The two cycles used to share one gate, and that gate was a purchased plugin, which meant an organization that had not bought the off-take loop recorded no signal history at all.

Cycle What it does Gate
Record collect, write signal_events, rebuild signal_stats recordCycleEnabledForOrg — the folded signals layer flag AND at least one activated domain layer
Decision fit, review and validate a policy on levers the mso-offtake plugin, plus a declared conversion metric

The record cycle runs inside processBriefingJob (apps/worker/src/processors/phase-b.ts) — the per-organization job that already writes the ledger — and nothing was added to the concurrency-one scheduled tick. The predicate is passed in, not imported, so packages/metrics stays free of the plugin host, and it is evaluated inside the rebuild's own row-level-security pin. Declining returns a skipped result rather than an error. The decision fan-out in apps/worker/src/processors/offtake.ts is byte-identical to its previous revision.

recordSignalEvents: true is the option that injects the ledger writer into the collector. It is passed at exactly one call site, the 07:00 job. The in-product refresh mutation builds the same dependencies WITHOUT it, so pressing refresh re-reads and re-ranks but writes no new instance.

One wiring point for the brief#

createBriefingDeps (packages/agent/src/briefing-deps.ts) is the only place the collector is wired to the brief generator, and both callers build from it: the scheduled job and the refresh mutation. Inside one organization-wide row-level-security pin it checks the signals layer flag, resolves the activated domains, and passes them as the intersection filter — a declaration whose domain the organization has not activated is never evaluated.

A failing read degrades rather than breaking: the collector returns an empty list, and the digest falls back to pack declaration order, which is byte-for-byte the document the product produced before ranking existed. A failing WRITE is reported through onWriteError and never costs the ranking or the brief.

The lever axis, per domain#

A decision is recorded against a lever, and the lever vocabulary used to be the five e-commerce social levers, typed as a union. It is now a string of at most 128 characters on offtake_models.lever and policy_models.lever, validated against the set the organization's activated domains DECLARE rather than against a compiled list. decisions.lever widened with them but stays nullable and untyped.

  • declaredLeversForActivatedDomains() returns the declared {domain, lever} pairs. An empty array is a stated abstention; undefined means the historical e-commerce vocabulary.
  • industryLeverPriorBp() returns 0 for a lever no domain declared, so an unknown lever gets no prior rather than a borrowed one.
  • policy_models.domain is NOT NULL DEFAULT 'ecom' and sits inside the natural key, between brand_id and lever. Two domains can therefore hold a model for the same-named lever without colliding.
  • The general playbook path now records a lever instead of NULL: playbookLeverRef(ruleId) produces playbook:<ruleId>, written on insert and on upsert so a re-scan backfills it.

Off-policy evaluation still defaults its axis to the e-commerce lever list. Widening that needs a matching change to the reconciliation assertion that pins it, which is recorded as not taken rather than done.

What a person can do with an instance#

Two mutations exist on the signals router (apps/web/src/server/routers/signal-events.ts), both rate-limited, both audit-logged, both idempotent through a returning-row check that answers CONFLICT on a second attempt:

  • signals.dismiss takes an instance id and a reason, requires owner or organization admin, checks brand access against the row's OWN brand, and returns the suppression window it just opened.
  • signals.markActed takes an instance id, gates on brand access only, and stamps acted_at.

There is no user interface for either one yet. The mutations, the permission gate, the audit trail and the suppression behaviour are shipped; the surface a person would press is not. Until it exists, the only writer of feedback is an API caller, which means weight_bp sits at its default for every organization.