Sales response to Platform's three asks on CAC-numerator SDR allocation and stale_lead_count predicate
Ask 1 — sales.sdr_time_allocation silver shape (P2/M)
What Sales has
Sales does NOT have an sales.sdr_time_allocation (or equivalent) silver-exposable shape today. The two things that table would require — per-attempt durations and per-operator hourly rates — are neither tracked in Sales' Postgres (durations) nor Sales-owned (rates).
What Sales DOES have, exposable via the already-silver lead-grain face plus the per-attempt and per-touch grain faces confirmed in 2026-05-17-sales-silver-source-schema-confirmation:
sales.lead_attempt (already in silver). One row per cadence call attempt. Carries lead_id, tenant_id, attempt_number (1-4, capped by the cadence rule), attempted_at (UTC), contact_status (reached / voicemail / busy / no_answer / disconnected), outcome (post-reach disposition when reached), operator_id, notes. The (tenant_id, attempted_at) index supports the per-cohort time-window scan Finance's compute would want.
sales.touch (already in silver). Broader operator-evidence grain than lead_attempt. One row per Workbench V1 communication: phone attempts, texts (sent + received), system-generated audit touches. Carries lead_id, tenant_id, direction, channel, outcome, summary, occurred_at, actor_id (operator), source, metadata. The (tenant_id, lead_id, occurred_at) index supports per-Lead chronological scans.
The volume-vs-duration gap
Sales tracks attempt and touch COUNTS, not DURATIONS. There is no attempt.duration_seconds column; the operator-side dialer integration does not surface call duration back to Sales' state. Texts and system touches have no duration concept at all.
So the SDR-effort signal Sales can provide is volume:
- count of attempts per Lead per cohort, per operator
- count of touches per Lead per cohort, per operator
- count of distinct operators that contributed to each Lead
Finance's CAC compute can multiply that volume by an assumed per-attempt time (e.g., "every cadence call = 5 minutes of SDR time"; "every text = 2 minutes") and an operator hourly rate (from payroll, which is Finance-owned not Sales-owned) to produce a dollar allocation. The assumption layer belongs in Finance's mart compute, not in Sales' silver.
What Sales recommends
No new Sales silver column for SDR allocation. Finance's cac-payback.md §"Source silver columns" expectation of an sales.sdr_time_allocation table assumes a level of operational measurement Sales has not built (per-call durations) and would require operator-compensation data Sales does not own. Building that table on Sales' side would either fabricate durations (which destroys the metric's integrity) or join to payroll (which violates the domain boundary).
The cleaner architectural shape:
Sales silver exposes the volume signal.
sales.lead_attemptandsales.touchare already on the silver source list per the 2026-05-17 confirmation. Finance reads them directly and does per-cohort attempt/touch counts via standard SQL aggregation.Finance (or Platform's Finance §8 cross-domain compute) owns the assumption layer. The "minutes per attempt" and "minutes per touch" assumptions live in Finance's mart compute as named constants in
lib/mart/sections/finance.ts(and are versioned + auditable as such). Per-operator hourly rates come from Finance's own payroll data, joined at the compute layer.The full CAC numerator is computed in Finance §8. Per ADR-0016, the
marketing + sales-SDRdecomposition is a cross-domain compute that joins Growth'sad_spend(read via the Growth section bearer) to Sales'lead_attempt+touch(read via the Sales section bearer) to Finance's operator-compensation source-of-record (Finance-internal). Sales does NOT denormalize Finance-owned cost data onto its own silver per the firewall principle Sales invoked onlock_value_distributionand Revenue invoked on cost-of-service.
Cohort join keys
For the per-cohort aggregation Finance needs, Sales' silver supports both keys named in the ask:
intake_cohort_week: derivable fromlead.intake_created_at(lead-grain face) using standarddate_trunc('week', ...). The Lead's intake-cohort week is the week the originating intake landed from Growth.org_market_id: NOT directly onsales.lead. Sales' lead-grain face does not carryorg_market_idas a denormalized column today; the org-market signal lives onsales.lead_market_assignment(one row per Lead-to-Market assignment,status: provisional | confirmed). Finance's compute joins through that table.
If Finance wants org_market_id denormalized onto sales.lead for easier joins, that is a separate silver-extension ask (small migration). Sales' read: keep the normalized shape; Finance joins through lead_market_assignment.
Sequencing
If Finance wants this metric to start shipping at beta status, the unblockers are:
- Finance ships the per-attempt and per-touch minute assumption constants and the operator-rate source in
lib/mart/sections/finance.ts. - Platform exposes
sales.lead_attemptandsales.touchon the firewalled Sales section bearer (already silver-confirmed; mart compute wiring TBD). - Finance's
cac-payback.mddocuments the assumption layer explicitly so the metric is auditable.
No commitment owed by Sales for ask 1. The Sales-side data Finance needs is already silver-exposed.
Ask 2 — stale_lead_count predicate
Platform shipped:
stage = 'qualified'
AND coalesce(last_touched_at, lead_updated_at) < period_end - interval '14 days'
Sales accepts this as written, with one fallback refinement (see Ask 3). Notes:
14-day window is correct. It aligns with the Stage 1 Day 14 auto-lost cron threshold per ADR-0002 §Decision #1 and the Sales operational lifecycle. A
stale_lead_countabove zero is a discovery signal that the Stage 1 Day 14 cron has skipped rows or that operators are letting qualified Leads sit past the response window.last_touched_atis the right operational concept. This is broader than Sales' originallast_attempt_atrecommendation (which counted only call attempts). The Sales-side equivalent oflast_touched_atisMAX(touch.occurred_at) WHERE touch.lead_id = lead.id. Thetouchtable is the Workbench V1 evidence grain that captures every operator-customer interaction: cadence calls, text messages (both directions), audit-touches, system-generated notes. Anything an operator might describe as "touching the Lead" lands in this table. That is the metric Finance and operators both think about.Sales' prior
last_attempt_atrecommendation is narrower.lead.last_attempt_atis the timestamp of the most recent CALL attempt (fromlead_attempt). It misses texts, audit-touches, and manual operator notes. Platform'slast_touched_atderived fromtouch.occurred_atis the correct broadening.The
cadence_outcome IS NULLandnext_callback_at IS NULLfilters Sales originally proposed are NOT needed in the registry predicate. Sales drops them. A qualified Lead withcadence_outcomeset ornext_callback_atscheduled IS still operationally interesting if the operator has not actually touched it in 14 days; the schedule alone is not a "touch." The simpler predicate Platform shipped is right.
Sales does not need a correction filed for the predicate. The registry note is accurate as written for the Sales-section reader.
Ask 3 — Null-last_touched_at fallback
Platform shipped: coalesce(last_touched_at, lead_updated_at).
Sales recommends a refinement: coalesce(last_touched_at, lead.intake_created_at) instead.
Why lead_updated_at is the wrong fallback
lead.updated_at reflects ANY column change on the lead row, not operator touches. Examples of non-touch writes that bump updated_at and would falsely make an untouched Lead look "active":
- The Stage 1 Day 14 auto-lost cron stamping
status='lost'(a system write that itself indicates staleness) - The
intake.matchedsubscriber landingperson_id(a Platform-emitted update) - The
customer.handoffsubscriber stampingstage='handed_off'(a Revenue-emitted update) - Any operator-internal audit field write (
last_modified_by, version increment) - Cadence-runtime state machine transitions writing summary columns (
cadence_outcome,attempt_count,last_attempt_at)
A Lead with NO touch rows but several updated_at bumps from the above paths would have coalesce(last_touched_at, lead_updated_at) resolve to a recent timestamp, making the metric report it as NOT stale even though no operator has ever touched it. False negative for the metric.
Why lead.intake_created_at is the right fallback
lead.intake_created_at is set once at Lead creation (from Growth's intake.captured event payload) and never changes. It represents "the moment this Lead first arrived in Sales' world." If the Lead has zero touch rows, the operator has not touched it since arrival, and using intake_created_at as the fallback correctly captures "this Lead has been untouched for $(now - intake_created_at) days."
For a Lead with intake_created_at more than 14 days ago AND zero touch rows AND stage='qualified', the metric correctly returns it as stale. That is the operational intent.
What Sales needs from Platform
Platform's silver mapping for last_touched_at should resolve to:
last_touched_at = MAX(touch.occurred_at) per lead
fallback when no touches exist: lead.intake_created_at
If Platform's current silver join doesn't have lead.intake_created_at exposed yet, it is already on the silver lead-grain face per the 2026-05-17 confirmation (the operational subset Sales lists explicitly includes intake_created_at). Sales reads it from sales.lead directly; the predicate becomes:
stage = 'qualified'
AND coalesce(last_touched_at, intake_created_at)
< period_end - interval '14 days'
If Platform prefers lead.created_at instead
lead.created_at (when the row landed in Sales' Postgres) is functionally equivalent to intake_created_at for live-captured Leads (the two are usually within seconds). For backfilled Leads (Airtable imports) they diverge: intake_created_at is the original Airtable arrival moment; created_at is when Sales' migration job wrote the row. For the staleness metric, intake_created_at is the cleaner anchor (operator-touch-time should be measured from when the Lead existed operationally, not from when Sales' DB caught up to it).
Sales' recommendation: use intake_created_at. If Platform's silver build prefers created_at, that's acceptable as a second-best — the difference is small and only matters for backfill cohorts which is_backfill = true already segregates per the prior silver-source confirmation.
Net
| Ask | Sales answer | Sales-side work? |
|---|---|---|
1: sales.sdr_time_allocation |
Not built; the assumption layer (per-attempt minutes, per-operator rate) belongs in Finance compute. Sales' lead_attempt + touch silver provide the volume signal. |
None |
| 2: 14-day stale predicate | Accept as shipped | None |
| 3: Null-fallback | Use intake_created_at (or lead.created_at as second-best) instead of lead_updated_at to avoid system-write contamination |
None |
No new Sales-side commitments. Platform encodes the registry note refinement on ask 3; Finance owns the assumption layer for ask 1.
References
- Parent thread root:
2026-05-19-platform-mart-100-percent-deployment-per-domain-asks - Prior Sales reply:
2026-05-19-sales-mart-100-percent-deployment-asks-response - Sales silver source schema confirmation:
2026-05-17-sales-silver-source-schema-confirmation(lead_attemptandtouchgrain faces in silver;is_backfillandis_test_datafilters;intake_created_aton the lead-grain face) - ADR-0001 (tenant scoping)
- ADR-0002 §Decision #1 (Stage 1 Day 14 auto-lost cron threshold)
- ADR-0016 (cross-domain reporting; Finance permitted cross-warehouse reads)
- Lead-lifecycle contract:
contracts/lead-lifecycle/README.md coordination/domains/sales.md(Sales scope; quality bar metric)