Signals harness: full design

One shared harness, one new PR, replacing #3274 (email field extraction) and #3389 (Onyx competitors) · target design, nothing built yet · 2026-10-22
  1. Goal and scope
  2. Architecture flow
  3. Sources: what the harness reads
  4. Source Config
  5. Completeness: when a day counts
  6. Signals (plugins) and their interface
  7. Where prompts live
  8. Signal Config
  9. Modes: off, dry-run, live
  10. How one run works, step by step
  11. Write policies
  12. Durability and crash safety
  13. Where state lives
  14. Code layout
  15. Onboarding and backfill a new org
  16. Test plan
  17. Done: when the old PRs close
  18. Decisions made
  19. Still open

1. Goal and scope ↑ top

Every "read a deal's activity, ask the model a question, write the answer back" job runs on one harness. The harness does the hard, shared parts once: scheduling, locking, knowing when data is complete, skipping unchanged items, calling the model, checking quotes, writing safely, and tracking cost. Each job is a small plugin that only knows its question and its rules.

In this PRshared core + two plugins: field_extraction (what #3274 does) and competitors (what #3389 does)
Turning a signal on for an orga Baserow row (Signal Config), not a deploy
Not in scopefetching from Gmail, Slack, ClickUp, Jira. Oliv's existing ingestion already does that.

2. Architecture flow ↑ top

already in Olivnew shared coresafety gate

A · Data already in Oliv (read-only)

EmailsGmail / Graph pull + association
Meetingstranscripts, summaries
Slack messagesPipedream pull + events
TicketsClickUp / Jira
Deals · Accounts · Fieldscurrent values, owners, CRM link
↓ Baserow reads only · no provider credentials · see Sources

B · Per-org config (Baserow rows)

Source Configorg × source: on/off, filters, lookback, readiness
Signal Configorg × signal: mode, settings, outputs → destination + policy, model, limits
↓

C · Schedule (worker pool oliv-celery-harness)

beatnightly run + weekly full sweep
dispatchevery org × signal whose mode isn't off
pick subjectsdeals/accounts with new or changed items
fenced leaseone run per (org, signal, subject); kill switch
↓ one task per subject · details in How one run works

D · Per-subject run (same for every signal)

read items
completeness gate
ledger diffchanged only
modelplugin prompt
quote check
decide()pure rules
publishpolicy + lease
ledger, then watermark
↓

E · Results

Deals card columnscard
Child rowsrows (e.g. OpptyCompetitors)
CRM AI fieldsif-empty / overwrite / review
Runs rowType = signal, status + cost

3. Sources: what the harness reads ↑ top

The harness never calls a provider. Oliv's ingestion already lands every source in Baserow with Deal/Company links and its own retries. The harness's job is narrower: notice what is new or changed per deal, and wait until a day's data is complete.

SourceLands in (Baserow)Linked toOnyx ws1197 today
EmailEmailsDeal, Account, Contacts29,829 rows
MeetingsMeetings (Transcript, AI summary); retranscripts in RecordingsDeal, Company22,580
SlackIntg | Slack | Messages → MessagesDeal, Company46,438 (41,972 deal-linked)
TicketsTickets (+ Comments)Deal, Company1,262 (456 deal-linked)
Not in Oliv at all: Zendesk, Linear, Intercom, Freshdesk, ServiceNow. A new ticket source should ingest into the shared Tickets table so the harness adapter doesn't change.

Adapter contract

One adapter per source. Each reads one Oliv table and returns normalized items.

Item {
  ref           "email:123" | "meeting:456" | "slack:789" | "ticket:42"   stable Oliv row
  source_id     provider identity (Message id, channel+ts, ClickUp id)
  kind          email | meeting | slack | ticket
  deal_ids, company_ids
  occurred_at   when it happened
  changed_at    Oliv-side change stamp
  voice         customer | internal | mixed
  text          what the model reads
  content_hash  sha256(text + links + status): a change means re-evaluate
  ready         False = known but not evaluable yet (e.g. no transcript)
}

SourceAdapter:
  changed(ws, since, until)   cheap: candidates by change stamp
  for_subject(ws, subject)    complete: every item linked to a deal/account
  completeness(ws, day)       complete | partial | failed

How changes are found (no schema change)

SourceCandidatesRe-read overlapKnown gap → caught by
EmailDate or Associated at since watermark (late-associated emails are not missed)2 daysnone known
MeetingStart time in last 14 days + pending items14 daysretranscript older than 14 days → weekly sweep
SlackLast Updated At / Last Activity At14 daysDeal link added later → weekly sweep
TicketUpdated at7 daysDeal link added by Oliv → weekly sweep
A stamp only finds candidates. The hash decides. The weekly sweep hashes every open deal's items with no model call unless something changed. An existing Last modified field (some orgs have one) is an optional speed-up set in Source Config, never required.

4. Source Config ↑ top

One Baserow row per org × source. Says which Oliv tables the org has and how to read them. No credentials field.

ColumnMeaningExample (Onyx, slack)
Sourcewhich adapterslack
Enabledread it or not. A disabled source never blocks a decision.yes
TableOliv table name (resolved by name)Messages
Filterswhat's in scopechannels with Pull Messages? on; skip projection status Skip
Voice rulewho is speakingSlack Connect → customer; internal channel → internal
Lookbackscan window / backfill window14 days / 180 days
Readinesswhen an item can be evaluatedprojection status = Yes
Change field (optional)a Last modified field to speed up scansnone on Messages

5. Completeness: when a day counts ↑ top

Before any signal reads a day, the harness records each source's status for that org and day: complete, partial or failed.

SourceComplete when
Emailevery connected mailbox pulled past day end, and nothing from that day still pending association
Meetingsevery meeting that day is ready (transcript + summary), didn't happen, or explicitly failed
Slackevery pull-enabled channel synced OK past day end, nothing waiting on projection
TicketsClickUp pull bound past day end, nothing owed in the recovery ledger (Jira: poll + weekly reconcile)

6. Signals (plugins) and their interface ↑ top

A plugin is one small Python module. Everything with I/O stays in the core.

class Signal(Protocol):
    name: str                          # "competitors"
    subject: "deal" | "account"
    sources: set[str]                  # {"meeting", "email"}
    settings_schema: dict              # shape of the org's Settings cell
    output_schema: dict                # what the model must return

    def prompt(subject, items, current, settings) -> str
    def decide(readings, items, current, settings) -> Decision   # pure, no I/O

Decision(
    rows=[...],        # child rows, e.g. one per (deal, competitor)
    values={...},      # named outputs, e.g. {"health": "Escalation", "description": ...}
)
The model returns facts with evidence, never decisions. Every fact carries {ref, quote}. The core drops any fact whose quote isn't in the cited item. decide() turns verified facts into outputs.

The two plugins in this PR

field_extractioncompetitors
Questionfill this org's AI-enabled CRM fields from evidencewhich vendors are on this deal, and how serious is it?
Subjectaccount + its dealsopen deal
Readsemails (meetings later)meetings + emails (Slack as background, see open item)
Outputsfields → CRM AI fields, policy per fieldhealth, description, reasoning → card; rows → OpptyCompetitors
First orgQu (ws1323), then Whatfix (ws681)Onyx (ws1197)

A new signal (escalation reasons, next steps, …) = a new plugin folder + a config row. The rule: only extract a shared piece once two plugins use it.

7. Where prompts live ↑ top

Each prompt has two parts: fixed rules in code (same for every org, versioned and tested) and org-specific text in Baserow (data the prompt reads).

competitorsfield_extraction
Signal column valuecompetitorsfield_extraction
Plugin folderservices/signals/plugins/competitors/services/signals/plugins/field_extraction/
Fixed prompt (code)competitors/prompt.py: what counts as a mention (testing_now, tested_decision_open, considering), output schema, verbatim-quote rulefield_extraction/prompt.py: "fill these fields from these items, quote the evidence, return JSON"
Org-specific textSignal Config → Settings: seller name, own products, partners, known vendors, competitor definition, closed stagesThe org's Fields table (exists today): one prompt per field. Custom AI prompt > Ai prompt > Prompt. Only fields with Enable AI evaluation run.
To change itrules: PR + dry-run parity · org facts: edit the Settings cellwrapper: PR · one field's question: edit that field's row (CS can)
Config rows never hold a whole prompt. Each Runs row records the prompt version and config version it used.

8. Signal Config ↑ top

One Baserow row per org × signal. The plugin is the question; the row says for which org, how hard to push, and where the answers go. Replaces config/deal_competitors.json and the old per-org flags.

ColumnMeaningOnyx · competitorsQu · field_extraction
Org, Signalwho, and which pluginOnyx (ws1197) · competitorsQu (ws1323) · field_extraction
Modeoff / dry-run / livedry-run firstdry-run (1-week shadow)
Settings JSONorg facts for the prompt and rules; checked against the plugin's schema, a bad row doesn't runseller, own products, partners, known vendors, competitor definition, closed stageswhich mailboxes count (e.g. Deal Driver users), lookback for a new org
Sources optionalwhich sources must be complete first; empty = all the plugin readsmeetings + emailsemails
Outputs JSONeach named output of decide() → destination + write policyhealth → Competition Status (test) · card
description → Competition Description (test) · card
reasoning → Competition Reasoning (test) · card
rows → OpptyCompetitors · rows
fields → AI-enabled CRM fields; policy per field, by field type, from the Fields table (ws1323 today: 38 if-empty, Next Step overwrite, 1 review)
Modelmodel + efforthigh effortmedium
Limitsmax subjects per night, $ cap per night; a cap stops cleanly, the rest go first next night10 deals while testing$ cap from shadow cost

9. Modes: off, dry-run, live ↑ top

ModeModel runs?Writes?Use for
offnononot onboarded, or paused
dry-runyesno; logs what it would write in the Runs rownew org, new prompt, parity checks
liveyesyes, per output policyafter dry run looks right
Emergency stop is separate: the Redis kill switch halts runs already in flight. Mode only affects the next dispatch.

10. How one run works, step by step ↑ top

  1. Dispatch reads every Signal Config row whose mode isn't off.
  2. Validate Settings and Outputs against the plugin. Invalid → that org × signal is skipped, Runs row says failed: config invalid, nothing written.
  3. Snapshot the row. An edit mid-run applies next run; one run never mixes two configs.
  4. Pick subjects: deals/accounts with candidate items since the watermark (with overlap). Weekly: every open subject.
  5. Lease per (org, signal, subject). A second delivery sees running/done and exits.
  6. Read items through the adapters.
  7. Completeness gate: any required source partial for the window → no decision, old output stays.
  8. Ledger diff: only items whose hash is new go on. Nothing changed → no model call. Not ready → kept as pending, no time limit.
  9. Model: plugin.prompt() → Codex turn with a shared cached prefix per org → facts with {ref, quote}.
  10. Quote check: drop any fact whose quote (numbers and dates too) isn't in the cited item.
  11. Decide: plugin.decide(), pure rules → values and rows.
  12. Publish each output with its policy. Lease checked before every Baserow send, retries included. Person edited since read → held. Readback after write. Dry-run: record only.
  13. Ledger written last, then the watermark moves. Runs row updated with counts, cost, errors. Limits checked between subjects.

11. Write policies ↑ top

PolicyWritesIf a person edited itReaches CRM?
cardAI-owned Deals columns, every runn/a, the column belongs to the signalnever
rowsAI-owned child rows, upsert by key (deal × competitor)rows a person made or hid are untouchednever
if-emptya field, only while it's empty (live CRM decides; unknown holds)never overwrittenyes
overwritea field, whenever the answer changesheld, not overwrittenyes
reviewthe field's AI Recommended columnuntouchedonly after a person accepts
No alerts. A signal's result is only a field update, shown on the dashboard (Deals card, child rows, CRM AI fields). Nobody is messaged.
If Onyx later wants competitors in a Salesforce field, register it as a normal AI field with review or if-empty. No new code path.

12. Durability and crash safety ↑ top

ConcernRule
Watermarkper org + source; moves only after the subject's ledger is written
Late dataevery scan re-reads [watermark − overlap, now − settle]; the hash ledger makes overlap free
Late transcriptnot-ready items stay pending with no time limit; evaluated when the transcript lands
Late email associationcandidates by sent date or Associated at
Slack edits / new repliesedit changes the hash; a reply is a new item and changes the thread root's hash
Duplicate jobsfenced lease per (ws, signal, subject); a rerun of a finished day makes zero model calls
Stalled workerlease check before every Baserow send, client retries included, so a worker that lost its lease can't write
Crash mid-runorder: read → evaluate → publish → ledger last → watermark. Crash before ledger → next run re-evaluates and publish is a no-op diff
Baserow errorsfail closed; ledger and watermark unchanged; retry next run
Stale watermarkolder than 2× the run interval → Runs row marked stale
Known limit: a write already on the wire when the lease is lost can still land. Real Baserow/Valkey/network timeouts are not tested yet.

13. Where state lives ↑ top

StoreHolds
S3 oliv-harness/signals/<ws>/<signal>/<subject>.jsonitem ledger (ref → hash), pending items · no expiry (the 90-day rule stays on email-harness-shadow/ only)
Baserow Runs, Type = signalone row per org × signal × day: complete / partial / failed, per-source completeness, counts, cost, errors, prompt + config version. Needs the signal select option added per workspace before the first run.
Baserow Source Config, Signal Configwhat's on, where outputs go, policies, caps
Redisleases, publish once-keys, kill switch (not durable on purpose)

14. Code layout ↑ top

services/signals/
  dispatch.py       beat → org × signal → subjects
  config.py         read + validate Source/Signal Config rows, snapshot
  lease.py          fenced lease, kill switch
  sources/          email, meeting, slack, ticket adapters + completeness
  ledger.py         S3 item hashes, pending, watermark
  runner.py         Codex turn, cached prefix, cost per run
  verify.py         quote check
  publish/          card, rows, if-empty, overwrite, review, readback
  runs.py           Runs rows (Type = signal)
  plugins/
    field_extraction/   prompt.py, schema, decide()
    competitors/        prompt.py, schema, decide()
jobs/tasks/signals.py   Celery entry points (harness pool)

15. Onboarding and backfill a new org ↑ top

  1. Ingestion backfills first with existing tools (oliv-initial-backfill for meetings/emails, Slack full-history pull, ClickUp/Jira importers).
  2. Add Source Config rows; wait until completeness reads complete for the whole range.
  3. Add a Signal Config row in dry-run with a lookback (e.g. 180 days) and a $ cap.
  4. Harness runs with an empty ledger, oldest first. Review the dry-run Runs rows.
  5. Flip Mode to live.
Cost: Baserow reads plus a few S3 objects per deal. Model spend only on new or changed items (Onyx: ~520 open deals × 4 sources).

16. Test plan ↑ top

Workflow-sim (real adapter, ledger, publisher code on a virtual clock; Baserow modelled from real column shapes), kept local. Each case runs against today's code as baseline and the new path, asserting final card/rows/fields and ledger contents.

#CaseMust hold
1Meeting on day D, transcript lands D+5evaluated on D+5 (today: never, 3-day cutoff)
2Email sent D−10, associated on Devaluated on D (today: skipped)
3Slack message edited after evaluationre-evaluated once
4New reply on 30-day-old threadevaluated; if event dropped, weekly sweep finds it
5Ticket comment, no stamp changeweekly sweep catches it
6Slack ingestion behindno decision, output unchanged, marked partial
7Baserow 429/500 during readfails closed; retry succeeds
8Crash after publish, before ledgerrerun: no-op publish, 1 model run not 2
9Crash after evaluate, before publishexactly one write
10Two deliveries of one subjectone runs, one exits, one write
11Rerun of a finished dayzero model calls
12Watermark 5 days staleRuns row marked stale; nothing missed
13Deal link added to old ticketweekly sweep picks it up
14New org, 180-day lookbackoldest first, stops at $ cap, dry run writes nothing
15Internal Slack is the only evidence for a vendorno row, no card change
16Weekly count check finds 3% Slack gapsource suspect, deals held, reconcile queued
17Org with no Last modified on Emailslate-associated email still caught
18Stalled worker, lease taken over, SDK retry firesstale worker writes nothing (carried from #3274)
19Person edits a field mid-runoverwrite holds; if-empty skips
20Health → Escalation, task redeliveredcard written once, same value
Mutations that must turn a case red: drop the overlap, remove ledger-last ordering, window emails on sent date only, give up on pending after 3 days, let internal Slack count as evidence, remove the per-send lease check.

17. Done: when the old PRs close ↑ top

  1. Competitors parity: dry run on the same Onyx deals; card values and rows match the old competitors code exactly (except the two fixed bugs, cases 1 and 2).
  2. Field extraction parity: replay the Qu and Whatfix shadow sets; same values, same or fewer wrong, cost no higher.
  3. Safety tests carried over from #3274: two workers, lease lost mid-write, retries after takeover, crash at each step, person edits mid-run.
  4. Config moved: old flags and config/deal_competitors.json become Signal Config rows; nothing else reads them.
When all four pass, #3274 and #3389 can close in favour of the new PR.

18. Decisions made (Karthik, 2026-10-22) ↑ top

QuestionDecision
Harness reads only Oliv tables, no provider credentialsyes
S3 ledger under oliv-harness/signals/, no expiryyes
Run status tableshared Runs table, Type = signal
Schema change for a meeting change stampno; 14-day re-scan + weekly sweep; emails by sent date or Associated at
Partial sourcesevery enabled source complete before deciding; per-signal override
#3389's two bugsfixed in the new PR, not patched in #3389
Weekly Slack/ClickUp count checkyes, built in, off by default
Goalone new PR covers both; old PRs close once it does

19. Still open ↑ top

AreaQuestion
SourcesDo internal (non-Slack-Connect) channel messages count as quotable competitor evidence? Until answered: background only.
OutputsOnyx competitors: Oliv only, or also a Salesforce field (review or if-empty)?
OutputsWhen do the (test) columns switch to the real Competition card?
ModelOne OpenAI key/project per signal so spend shows per signal? (adds a Key column)
FleetOnyx on its own ECS service, or the shared harness pool with a workspace allowlist?
OverlapFeed competitor health into post-call's commercial_pressure, or keep separate?
OrderMerge #3274 (READY @95103004c) first and build on it, or fold it into the new PR?
OnyxOther ticket tools? Which Slack channels are customer-facing? Are AI channel → account links right? Do Field Requests count as deal signals?

Sources: SIGNALS-HARNESS-DESIGN-2026-10-22.md and SOURCES-DESIGN-2026-10-22.md (thr_2xt33ehbpp), Karthik's decisions 2026-10-22. ↑ back to top