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 ↑ 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)
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
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.
| Source | Lands in (Baserow) | Linked to | Onyx ws1197 today |
| Email | Emails | Deal, Account, Contacts | 29,829 rows |
| Meetings | Meetings (Transcript, AI summary); retranscripts in Recordings | Deal, Company | 22,580 |
| Slack | Intg | Slack | Messages → Messages | Deal, Company | 46,438 (41,972 deal-linked) |
| Tickets | Tickets (+ Comments) | Deal, Company | 1,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)
| Source | Candidates | Re-read overlap | Known gap → caught by |
| Email | Date or Associated at since watermark (late-associated emails are not missed) | 2 days | none known |
| Meeting | Start time in last 14 days + pending items | 14 days | retranscript older than 14 days → weekly sweep |
| Slack | Last Updated At / Last Activity At | 14 days | Deal link added later → weekly sweep |
| Ticket | Updated at | 7 days | Deal 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.
| Column | Meaning | Example (Onyx, slack) |
| Source | which adapter | slack |
| Enabled | read it or not. A disabled source never blocks a decision. | yes |
| Table | Oliv table name (resolved by name) | Messages |
| Filters | what's in scope | channels with Pull Messages? on; skip projection status Skip |
| Voice rule | who is speaking | Slack Connect → customer; internal channel → internal |
| Lookback | scan window / backfill window | 14 days / 180 days |
| Readiness | when an item can be evaluated | projection status = Yes |
| Change field (optional) | a Last modified field to speed up scans | none 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.
| Source | Complete when |
| Email | every connected mailbox pulled past day end, and nothing from that day still pending association |
| Meetings | every meeting that day is ready (transcript + summary), didn't happen, or explicitly failed |
| Slack | every pull-enabled channel synced OK past day end, nothing waiting on projection |
| Tickets | ClickUp pull bound past day end, nothing owed in the recovery ledger (Jira: poll + weekly reconcile) |
- Rule: every enabled source must be complete before deciding. A signal can relax this with
Sources in its Signal Config row.
- Partial: no decision for that subject, old output stays, watermark doesn't move, retried next run. After 3 days the run report shows "evidence incomplete".
- Weekly count check (built in, off by default): a separate ingestion-side job compares Slack and ClickUp counts for the past 7 days with what's in Oliv. Over 1% gap → source marked
suspect (counts as not complete) and ingestion's reconcile is queued.
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_extraction | competitors |
| Question | fill this org's AI-enabled CRM fields from evidence | which vendors are on this deal, and how serious is it? |
| Subject | account + its deals | open deal |
| Reads | emails (meetings later) | meetings + emails (Slack as background, see open item) |
| Outputs | fields → CRM AI fields, policy per field | health, description, reasoning → card; rows → OpptyCompetitors |
| First org | Qu (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).
| competitors | field_extraction |
| Signal column value | competitors | field_extraction |
| Plugin folder | services/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 rule | field_extraction/prompt.py: "fill these fields from these items, quote the evidence, return JSON" |
| Org-specific text | Signal Config → Settings: seller name, own products, partners, known vendors, competitor definition, closed stages | The 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 it | rules: PR + dry-run parity · org facts: edit the Settings cell | wrapper: 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.
| Column | Meaning | Onyx · competitors | Qu · field_extraction |
| Org, Signal | who, and which plugin | Onyx (ws1197) · competitors | Qu (ws1323) · field_extraction |
| Mode | off / dry-run / live | dry-run first | dry-run (1-week shadow) |
| Settings JSON | org facts for the prompt and rules; checked against the plugin's schema, a bad row doesn't run | seller, own products, partners, known vendors, competitor definition, closed stages | which mailboxes count (e.g. Deal Driver users), lookback for a new org |
| Sources optional | which sources must be complete first; empty = all the plugin reads | meetings + emails | emails |
| Outputs JSON | each named output of decide() → destination + write policy | health → 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) |
| Model | model + effort | high effort | medium |
| Limits | max subjects per night, $ cap per night; a cap stops cleanly, the rest go first next night | 10 deals while testing | $ cap from shadow cost |
9. Modes: off, dry-run, live ↑ top
| Mode | Model runs? | Writes? | Use for |
off | no | no | not onboarded, or paused |
dry-run | yes | no; logs what it would write in the Runs row | new org, new prompt, parity checks |
live | yes | yes, per output policy | after 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
Dispatch reads every Signal Config row whose mode isn't off.
Validate Settings and Outputs against the plugin. Invalid → that org × signal is skipped, Runs row says failed: config invalid, nothing written.
Snapshot the row. An edit mid-run applies next run; one run never mixes two configs.
Pick subjects: deals/accounts with candidate items since the watermark (with overlap). Weekly: every open subject.
Lease per (org, signal, subject). A second delivery sees running/done and exits.
Completeness gate: any required source partial for the window → no decision, old output stays.
Ledger diff: only items whose hash is new go on. Nothing changed → no model call. Not ready → kept as pending, no time limit.
Model: plugin.prompt() → Codex turn with a shared cached prefix per org → facts with {ref, quote}.
Quote check: drop any fact whose quote (numbers and dates too) isn't in the cited item.
Decide: plugin.decide(), pure rules → values and rows.
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.
Ledger written last, then the watermark moves. Runs row updated with counts, cost, errors. Limits checked between subjects.
11. Write policies ↑ top
| Policy | Writes | If a person edited it | Reaches CRM? |
card | AI-owned Deals columns, every run | n/a, the column belongs to the signal | never |
rows | AI-owned child rows, upsert by key (deal × competitor) | rows a person made or hid are untouched | never |
if-empty | a field, only while it's empty (live CRM decides; unknown holds) | never overwritten | yes |
overwrite | a field, whenever the answer changes | held, not overwritten | yes |
review | the field's AI Recommended column | untouched | only 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
| Concern | Rule |
| Watermark | per org + source; moves only after the subject's ledger is written |
| Late data | every scan re-reads [watermark − overlap, now − settle]; the hash ledger makes overlap free |
| Late transcript | not-ready items stay pending with no time limit; evaluated when the transcript lands |
| Late email association | candidates by sent date or Associated at |
| Slack edits / new replies | edit changes the hash; a reply is a new item and changes the thread root's hash |
| Duplicate jobs | fenced lease per (ws, signal, subject); a rerun of a finished day makes zero model calls |
| Stalled worker | lease check before every Baserow send, client retries included, so a worker that lost its lease can't write |
| Crash mid-run | order: read → evaluate → publish → ledger last → watermark. Crash before ledger → next run re-evaluates and publish is a no-op diff |
| Baserow errors | fail closed; ledger and watermark unchanged; retry next run |
| Stale watermark | older 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
| Store | Holds |
S3 oliv-harness/signals/<ws>/<signal>/<subject>.json | item ledger (ref → hash), pending items · no expiry (the 90-day rule stays on email-harness-shadow/ only) |
Baserow Runs, Type = signal | one 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 Config | what's on, where outputs go, policies, caps |
| Redis | leases, 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
Ingestion backfills first with existing tools (oliv-initial-backfill for meetings/emails, Slack full-history pull, ClickUp/Jira importers).
Add Source Config rows; wait until completeness reads complete for the whole range.
Add a Signal Config row in dry-run with a lookback (e.g. 180 days) and a $ cap.
Harness runs with an empty ledger, oldest first. Review the dry-run Runs rows.
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.
| # | Case | Must hold |
| 1 | Meeting on day D, transcript lands D+5 | evaluated on D+5 (today: never, 3-day cutoff) |
| 2 | Email sent D−10, associated on D | evaluated on D (today: skipped) |
| 3 | Slack message edited after evaluation | re-evaluated once |
| 4 | New reply on 30-day-old thread | evaluated; if event dropped, weekly sweep finds it |
| 5 | Ticket comment, no stamp change | weekly sweep catches it |
| 6 | Slack ingestion behind | no decision, output unchanged, marked partial |
| 7 | Baserow 429/500 during read | fails closed; retry succeeds |
| 8 | Crash after publish, before ledger | rerun: no-op publish, 1 model run not 2 |
| 9 | Crash after evaluate, before publish | exactly one write |
| 10 | Two deliveries of one subject | one runs, one exits, one write |
| 11 | Rerun of a finished day | zero model calls |
| 12 | Watermark 5 days stale | Runs row marked stale; nothing missed |
| 13 | Deal link added to old ticket | weekly sweep picks it up |
| 14 | New org, 180-day lookback | oldest first, stops at $ cap, dry run writes nothing |
| 15 | Internal Slack is the only evidence for a vendor | no row, no card change |
| 16 | Weekly count check finds 3% Slack gap | source suspect, deals held, reconcile queued |
| 17 | Org with no Last modified on Emails | late-associated email still caught |
| 18 | Stalled worker, lease taken over, SDK retry fires | stale worker writes nothing (carried from #3274) |
| 19 | Person edits a field mid-run | overwrite holds; if-empty skips |
| 20 | Health → Escalation, task redelivered | card 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
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).
Field extraction parity: replay the Qu and Whatfix shadow sets; same values, same or fewer wrong, cost no higher.
Safety tests carried over from #3274: two workers, lease lost mid-write, retries after takeover, crash at each step, person edits mid-run.
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
| Question | Decision |
| Harness reads only Oliv tables, no provider credentials | yes |
S3 ledger under oliv-harness/signals/, no expiry | yes |
| Run status table | shared Runs table, Type = signal |
| Schema change for a meeting change stamp | no; 14-day re-scan + weekly sweep; emails by sent date or Associated at |
| Partial sources | every enabled source complete before deciding; per-signal override |
| #3389's two bugs | fixed in the new PR, not patched in #3389 |
| Weekly Slack/ClickUp count check | yes, built in, off by default |
| Goal | one new PR covers both; old PRs close once it does |
19. Still open ↑ top
| Area | Question |
| Sources | Do internal (non-Slack-Connect) channel messages count as quotable competitor evidence? Until answered: background only. |
| Outputs | Onyx competitors: Oliv only, or also a Salesforce field (review or if-empty)? |
| Outputs | When do the (test) columns switch to the real Competition card? |
| Model | One OpenAI key/project per signal so spend shows per signal? (adds a Key column) |
| Fleet | Onyx on its own ECS service, or the shared harness pool with a workspace allowlist? |
| Overlap | Feed competitor health into post-call's commercial_pressure, or keep separate? |
| Order | Merge #3274 (READY @95103004c) first and build on it, or fold it into the new PR? |
| Onyx | Other 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