signals — Module Spec
1. Purpose
signals is the feature/forecast/outcome store of the agents-v2/v3 build — where the not-yet-built agents module (Phase 5) will read the bitemporal facts a decision needs ("what did we know about this entity, and when did we know it") and write back the measured outcomes of its own past actions. It is Phase 4 of a 6-phase agents-v2/v3 build authorization (Phase 1 reopened platform for the module registry + AI-capacity layer, PROJECT_DECISIONS #62; Phase 2 reopened ai for the model-registry/deployment/prompt/routing layer, PROJECT_DECISIONS #63; Phase 3 built semantics, the shared business ontology, PROJECT_DECISIONS #64) — and is this build's own explicitly-designated highest-risk phase, combining 3 mechanisms no prior phase combined in one schema: table partitioning, a split-authority pattern for an invariant a native partitioned index structurally cannot express, and SECURITY DEFINER tenant-isolation functions.
Like semantics, signals has zero v1 antecedent — confirmed: v1 had no feature-store, forecast, anomaly-detection, or experimentation concept of any kind. Unlike semantics (definitional, low-volume, human-curated), signals is observational, high-volume, bitemporal, append-only, partitioned — the opposite operational profile.
See PROJECT_DECISIONS #65 for the full build record, including the 4 named critical guards, the 2 privilege-mechanics gaps found during the migration-apply pass, and the Section 4 self-audit fix.
2. Ownership
Owns — 11 tables, 111 columns:
| Table | Cols | Tenant-scoped? | Partitioned? | Role |
|---|---|---|---|---|
feature_definition |
6 | No (global) | No | Standard A2b catalog — the stable, versioned concept a feature is |
feature_version |
7 | No (global) | No | A revision of a feature definition — how it's computed |
experiment |
6 | No (global) | No | Standard A2b catalog — the stable, versioned concept an experiment is |
experiment_version |
9 | No (global) | No | A revision of an experiment — immutable treatment logic, allows_reentry/washout_days |
feature_value |
10 | Yes | Yes (weekly, recorded_at) |
Bitemporal feature-value store, REVOKE ALL from authenticated — function-only read |
forecast |
11 | Yes | Yes (weekly, recorded_at) |
Bitemporal forecast store, same access shape as feature_value |
anomaly_score |
10 | Yes | Yes (weekly, recorded_at) |
Bitemporal anomaly-score store, same access shape as feature_value |
experiment_assignment |
19 | Yes | No | Which subject got which treatment arm; RLS-enabled |
experiment_exposure_event |
5 | Yes | No | Append-only exposure log; RLS-enabled |
outcome_observation |
19 | Yes | Yes (monthly, created_at) |
Append-only, remeasurable outcome ledger; RLS-enabled |
outcome_authority |
9 | Yes | No | The split-authority pattern — exactly one authoritative observation per scope |
| Total | 111 | 7 of 11 tenant-scoped | 4 of 11 partitioned |
Does NOT own:
- The
agentsmodule's own runtime (Phase 5, not yet built) —signalsstores facts and outcomes for an agent decision to read/write; it does not itself decide, act, or execute anything. - The shared business ontology —
semantics.metric_definition/goal_definition/attribution_model_definition(already built, Phase 3).signals.outcome_observation.attribution_model_version_idis a real FK intosemantics.attribution_model_version;signalsdoes not duplicate or reinvent that vocabulary. - Agent identity/authority —
identity.agent_identity/agent_duty_grant(already built and locked).signalsintroduces no new authority mechanism of its own (see §8). - The decision-context snapshot
agents.decision_context_snapshot(Phase 5, not yet built) —signals.lock_experiment_causal_basis()is the function this table's own future trigger will call, but theCREATE TRIGGER ... ON agents.decision_context_snapshotstatement itself is deferred to Phase 5, which builds that table.
3. Layer & Dependencies
Foundation layer (per SCHEMA_CONVENTIONS.md's foundation/business/consumer taxonomy) — infrastructure a future agent capability reads and writes, not a nursery-vertical product surface, structurally the same positioning as ai, approvals, and semantics.
Depends on:
platform—tenant.id(the FK target for all 7 tenant-scoped tables'tenant_idcolumn) and the newplatform.current_tenant_id()helper function this phase adds (a thin wrapper over the session GUC every RLS policy andtenantDB()/consumerDB()call already sets — the tenant-scope mechanism the 3SECURITY DEFINERas-of functions derive their tenant boundary from, instead of a spoofable parameter).semantics—attribution_model_version.id(real FK fromoutcome_observation.attribution_model_version_id) — the first real consumer ofsemantics's own attribution-model family, exactly assemantics's own Phase 3 doc predicted this table would do, replacing v2's original free-textattribution_methodcolumn.
Depends on NO domain/nursery-vertical module — same reverse-dependency discipline approvals/semantics established for themselves: signals never references crm, inventory, pos, or any other product module directly. feature_value.entity_type/entity_ref and experiment_assignment.subject_type/subject_ref are polymorphic, no-FK pointers that CAN reference any table in the system without signals ever being blocked by, or blocking, that table's own schema evolution.
Will depend on — agents.agent_action (Phase 5, not yet built): outcome_observation.agent_action_id/outcome_authority.agent_action_id are disclosed forward-refs, bare uuid, no FK today; and agents.decision_context_snapshot (Phase 5): lock_experiment_causal_basis()'s own CREATE TRIGGER statement is deferred until that table exists.
Depended on by: the not-yet-built agents module (Phase 5) — this is the entire reason signals exists as its own phase, built before agents. agents is expected to write feature_value/forecast/anomaly_score rows (via an elevated service connection, not authenticated), read them back through the 3 SECURITY DEFINER as-of functions once agent_reader (Phase 6) exists, write outcome_observation rows measuring its own past actions' effect, and drive experiment_assignment/experiment_exposure_event for any agent decision that runs as a randomized experiment. Not live today — signals has zero real callers at this phase, by design (see §7).
4. Tables — grouped
Feature/Experiment catalog family (4, standard A2b pattern): feature_definition → feature_version / experiment → experiment_version (the latter carrying B4's own 2 required decisions: allows_reentry, washout_days).
Bitemporal value family (3, PARTITIONED weekly by recorded_at): feature_value / forecast / anomaly_score — the same shape (polymorphic entity_type/entity_ref, as_of business-truth time, recorded_at ERP-knowledge-time partition key, lineage JSONB), REVOKE ALL from authenticated, readable only through 3 SECURITY DEFINER as-of functions.
Experiment causal-inference family (2, NOT partitioned): experiment_assignment (who got which treatment arm, assigned_at forgery-proofed by a trigger) → experiment_exposure_event (append-only, validated against its own assignment's assigned_at, drives the causal-basis-lock/contamination-flag state machine).
Outcome split-authority family (2): outcome_observation (PARTITIONED monthly by created_at, append-only/remeasurable) + outcome_authority (NOT partitioned, the real "exactly one authoritative observation per scope" enforcement mechanism, maintained by a native atomic upsert).
Full column-level detail (types, nullability, defaults, CHECK constraints, indexes, triggers, partition mechanics) lives in docs/database/schema_docs/signals.md — not restated here.
5. Capabilities
signals defines no user-facing capability and no agent-authored write path of its own in this phase — it is infrastructure a future agent decision reads and writes through, not a screen a human uses.
- Enforced bitemporal reads —
get_feature_as_of()/get_forecast_as_of()/get_anomaly_score_as_of()structurally cannot return a fact the caller "shouldn't have known yet" — a fact recorded (recorded_at) after the caller's ownknowledge_cutoffparameter is invisible, regardless of how far in the business past (as_of) it describes. This is not a service-layer discipline; it's a DB-level guarantee live-reproduced against exactly this scenario (a fact learned 1 day after a decision was made 30 days earlier). - Tenant-safe function-mediated access — the 3 as-of functions derive tenant scope from
platform.current_tenant_id()(the session GUC), not a caller-supplied parameter, closing a real spoofing vector a naiveSECURITY DEFINERdesign would have left open. - Partition-safe split-authority enforcement —
outcome_authority's own PK guarantees exactly one authoritative observation per(tenant, agent_action, outcome_type, measurement_window)scope, maintained via a native, race-freeINSERT ... ON CONFLICTupsert, correctly across however many monthly partitionsoutcome_observationitself has split its history into. - Forgery-proof and contamination-aware experimentation —
assigned_atcannot be backdated (a trigger overwrites it unconditionally); an exposure event cannot predate its own assignment; and a genuinely-earlier exposure arriving after the causal basis is already locked is flagged (contamination_status,outcome_authority.remeasurement_required), never silently absorbed into history.
No AI write path is implemented this phase — schema-only build (Drizzle + migration + tests), no SignalsService. See §6 and §9.
6. Service Contract — SignalsService
No SignalsService exists yet — schema-only this phase, see PROJECT_DECISIONS #65. This matches every phase of the agents-v2/v3 build so far (Phase 1's platform reopen, Phase 2's ai reopen, and Phase 3's semantics build were all schema-only too) — every phase of this build stays schema-only until Phase 5 lands the agents module itself. Downstream code (once it exists) would query signals.* directly via Drizzle for the 4 catalog + 2 experiment-causal-inference tables, and would call the 3 SECURITY DEFINER as-of functions for feature_value/forecast/anomaly_score reads (once agent_reader, Phase 6, is granted EXECUTE — see §11).
The mechanisms that matter most for correctness — the split-authority upsert, the bitemporal-read enforcement, the assigned_at forgery guard, and the retroactive-contamination-race fix — are NOT waiting on that service layer to be correct: they are already live, atomic, and enforced directly by the schema regardless of what future application code does or doesn't do.
7. Data-Flow / Population Model
signals is unusual among this project's modules in the same way ai's agent-runtime cluster and semantics were at their own first pass: it has no first-party UI and is not populated by a user filling out a form in this module directly.
feature_value/forecast/anomaly_scoreare written by backend service jobs (a feature-computation pipeline, a forecasting job, an anomaly-detection job), never directly by tenant-scoped application code —REVOKE ALLfromauthenticatedon all 3 tables (parent and every partition) enforces this structurally, not just by convention. The sole sanctioned read path for a tenant-scoped caller is the 3SECURITY DEFINERas-of functions, and even those have noGRANT EXECUTEto any role yet (see §11).- The 4 catalog tables (
feature_definition,feature_version,experiment,experiment_version) are populated by platform/service_role—authenticatedgetsSELECTonly, with an explicitREVOKE INSERT, UPDATE, DELETE. experiment_assignment/experiment_exposure_eventare RLS-enabled and tenant-scoped, butauthenticatedhas no bespokeINSERTgrant on either in this migration (only the schema-wide defaultSELECT, plusexperiment_assignment's own scoped-columnUPDATE) — consistent with the design's own framing that assignment/exposure recording is a randomization-service responsibility, not a tenant-application write path, mirroringfeature_value's own "written by an elevated service connection" convention.outcome_observation/outcome_authorityfollow the same pattern asexperiment_assignmentfor inserts, with one exception:outcome_authoritygets a genuineGRANT SELECT, INSERT, UPDATEtoauthenticated, since the promotion trigger that maintains it runs under the INSERTING session's own privileges (notSECURITY DEFINER) — but the intended access path is exclusively throughpromote_authoritative_observation()'s own upsert, a disclosed workflow convention rather than a DB-enforced restriction.agents(deferred, Phase 5) is the expected FIRST REAL READER AND WRITER of this entire schema. Until it's built, every table insignalscan be populated (by a future backend service/migration pass) and queried, but nothing in the live system actually consumes it yet — the same deliberate "build the substrate before the speaker" sequencingsemanticsestablished for itself.
8. Agent Authority Mapping
signals introduces no new authority mechanism. It is not itself a consumer of identity.agent_duty_grant in this phase either — like semantics, signals defines no agent-authored write path at all in this build (see §5 and §7): the 4 global catalog tables are platform-write-only; feature_value/forecast/anomaly_score are elevated-service-write-only; experiment_assignment/experiment_exposure_event/outcome_observation have no bespoke authenticated INSERT grant either. Once agents (Phase 5) is built and begins writing to this schema, agent actions will be logged the same way every other module's agent actions are — via ai.agent_execution — but that is ai's mechanism, not a new one signals invents.
9. Cross-Module Seams
Cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated here). Key relationships:
| Direction | Contract |
|---|---|
| Signals → Platform | every tenant-scoped table's tenant_id → platform.tenant.id (enforced FK, RLS scope where RLS applies). platform.current_tenant_id() (new this phase) is the tenant-derivation mechanism the 3 SECURITY DEFINER as-of functions call instead of trusting a caller-supplied parameter. |
| Signals → Semantics | outcome_observation.attribution_model_version_id → semantics.attribution_model_version.id (enforced FK, NOT NULL) — the first real consumer of semantics's attribution-model family, replacing v2's original free-text attribution_method column exactly as semantics's own Phase 3 doc predicted. |
| Signals (internal, disclosed forward-ref) | outcome_observation.agent_action_id / outcome_authority.agent_action_id are plain uuid, NO FK — agents.agent_action (Phase 5) doesn't exist yet (confirmed live: zero tables in the agents schema as of this phase). Logged in docs/open-items/OPEN_ITEMS.md (signals | FK). |
| Signals (internal, disclosed deferred GRANT) | get_feature_as_of() / get_forecast_as_of() / get_anomaly_score_as_of() have REVOKE EXECUTE FROM PUBLIC but no GRANT EXECUTE to any role — agent_reader (Phase 6) doesn't exist yet. Matches this design's own explicit allowance to "stub the REVOKE ahead of the role's full build." Logged in docs/open-items/OPEN_ITEMS.md (signals | FK). |
| agents (deferred, Phase 5) → Signals | (forward, not yet built) expected to write feature_value/forecast/anomaly_score via an elevated service connection, read them back through the 3 as-of functions once agent_reader exists, write outcome_observation measuring its own past actions, and drive experiment_assignment/experiment_exposure_event for randomized-experiment decisions. agents.decision_context_snapshot's own future CREATE TRIGGER for lock_experiment_causal_basis() also lands here. |
10. Design Rationale
- DR-1 — the split-authority pattern is the load-bearing mechanism for BLOCKER 2. A native unique/partial-unique index enforcing "exactly one authoritative observation per scope" cannot survive
PARTITION BY RANGE(created_at)— Postgres requires the partition key in every unique constraint on a partitioned table, which would silently narrow the guarantee to per-partition-month scope.outcome_authority, a genuinely separate unpartitioned table whose own PK is the real enforcement, closes this without weakening the guarantee. Seedocs/database/schema_docs/signals.md's own "Design Patterns Summary" for the full live-reproduction record. - DR-2 — the
SECURITY DEFINERas-of functions derive tenant scope from a session GUC, not a parameter, because a parameter is spoofable by construction. No amount of "validate the parameter" logic closes a hole where the parameter itself is attacker-controlled; removing the parameter entirely (deriving scope fromplatform.current_tenant_id()instead) is the only structural fix (BLOCKER 1). - DR-3 — 2 constraints were corrected from the design doc's own literal SQL during the build itself, not by a later audit:
anomaly_score's uniqueness (design said(id, tenant_id), corrected to(id, tenant_id, recorded_at)) andoutcome_observation's second unique constraint (design omittedcreated_at, corrected to include it, backstopped by a genuine cross-partition trigger since the corrected constraint alone would silently narrow the invariant to per-partition-month scope). Both are the same structural class of error BLOCKER 2 itself documents foris_authoritative, just not caught by that section's own adversarial pass for these 2 sibling constraints — found live during THIS phase's own migration-apply pass. Seedocs/database/schema_docs/signals.mdfor the full disclosure. - DR-4 —
assigned_atforgery prevention overwrites rather than validates.DEFAULT now()only fires when a column is omitted; it is not a validation rule and does nothing to stop an explicit backdatedINSERT.force_assignment_timestamp()unconditionally overwrites the value withclock_timestamp()(the true wall-clock instant, notnow(), which is frozen at transaction start and therefore constructible by a caller holding a transaction open) — a structural fix, not a stricter check (BLOCKER 3, Fix 1). - DR-5 — the retroactive-exposure-contamination race is closed by row-locking the assignment first, in both functions that touch it.
lock_experiment_causal_basis()andupdate_first_eligible_exposure()bothSELECT ... FOR UPDATEthe target assignment row before reading or writing its own state — this is what makes the two functions serialize correctly against each other; the contamination-flagging branch logic alone, without the lock, would not have closed the live-reproduced race (BLOCKER 3, Fix 2). - DR-6 —
subject_type/subject_refresolve a genuine internal naming inconsistency in the design doc itself, disclosed rather than silently picked: the design's own column-list prose saidassignment_unit, its own "Uniqueness" line saidsubject_type, subject_ref— treated as the same single concept, named per the more specific, typed, structurally-consistent form matching this codebase's established polymorphic-pointer convention. - DR-7 — 2 privilege-mechanics gaps were found live during the migration-apply pass, distinct from the design-phase guard verifications:
ALTER FUNCTION ... OWNER TO signals_function_ownerneeded both role-membership and schema-CREATEgrants the migration-running role didn't have by default (this Supabase stack'spostgresrole is not a true superuser); and theSECURITY DEFINERfunction body's own call toplatform.current_tenant_id()neededsignals_function_ownerto holdUSAGEon schemaplatform, a genuinely separate requirement fromEXECUTEon the function itself.
11. Deferred / Future Items
All items tracked in docs/open-items/OPEN_ITEMS.md, attributed to signals (PROJECT_DECISIONS #65). Summary for context:
| Item | Status | Trigger |
|---|---|---|
outcome_observation.agent_action_id / outcome_authority.agent_action_id — bare uuid, no FK |
open, disclosed forward-ref | Wire both as real composite FKs → agents.agent_action(id, tenant_id) when Phase 5 lands that table. |
get_feature_as_of() / get_forecast_as_of() / get_anomaly_score_as_of() — REVOKE EXECUTE FROM PUBLIC, no GRANT EXECUTE to any role |
open, disclosed deferred GRANT | Wire GRANT EXECUTE ... TO agent_reader on all 3 functions when Phase 6 lands that role. |
lock_experiment_causal_basis()'s own CREATE TRIGGER ... ON agents.decision_context_snapshot |
open, disclosed forward-ref | The function itself is built this phase (touches only signals.experiment_assignment); the trigger statement is deferred until Phase 5 builds agents.decision_context_snapshot. |
SignalsService |
open | No service layer exists yet for any of the 11 tables — schema-only this phase, matching every phase of this build. Trigger: when a service layer is built, most likely alongside or just before Phase 5's agents module, since agents is this schema's first real read/write consumer. |
agents real consumption |
open | signals has zero real callers today — a deliberate build-the-substrate-first sequencing. Trigger: Phase 5 (agents) of the same build authorization. |