A partner-sourced deal has 90 days from registration to reach PoC. If it does, the partner has completed the cycle and a reward record opens for admin review. If it does not, the deal is automatically parked in ReNurture and the customer is released for another partner to register.
While a deal is live, the customer it names is locked to the registering partner — cross-partner, for 90 days. Every stage change is recorded append-only, which is what makes the funnel dashboard and any reward dispute answerable.
This page documents the whole mechanism. The design rationale lives in docs/superpowers/specs/2026-10-07-partner-90-day-lifecycle-design.md.
Pipeline → Workshop & Demo → PoC → Negotiation → Finance Review → Closed
Defined once in lib/deal-lifecycle.ts and re-exported from lib/constants/partner-forms.ts, so a caller importing from either can never see two lists.
PoC Success and PoC Fail are not stages. The whiteboard drew them on one line, but folding an outcome into the stage vocabulary makes "how many deals are at PoC" unanswerable without knowing which values count, and leaves the forward path out of PoC Success ambiguous. The outcome lives in its own nullable column, poc_outcome ("success" | "fail"), orthogonal to stage.
ReNurture is not a stage either — it is a portal_status, alongside registered, closed_won, closed_lost and cancelled.
Derived from funnel position, not tabulated — inserting a stage cannot leave a transition table out of step:
| From | May move to |
|---|---|
| Pipeline | Workshop & Demo, Closed |
| Workshop & Demo | PoC, Pipeline, Closed |
| PoC | Negotiation, Workshop & Demo, Closed |
| Negotiation | Finance Review, PoC, Closed |
| Finance Review | Closed, Negotiation |
| Closed | — (terminal) |
One step back is legal (a demo that needs a second round). A forward skip is not: funnel conversion is computed by differencing stage-entry counts, so a deal jumping Pipeline → PoC would permanently under-count Workshop & Demo and make the drop-off between them a lie.
legalNextStages() drives both the stage buttons in the UI and the canTransition() check in the write path, so a button can never appear for a move the server then refuses.
Registration offers REGISTRABLE_STAGES — everything except Closed. Registering a deal as closed would open one that can never transition while still holding a 90-day lock on the customer.
This is the subtlest part of the system.
| Clock | Governs | Resets on activity? | Anchor | Fires |
|---|---|---|---|---|
| Time-to-PoC (new) | pre-PoC deals | No | poc_clock_started_at | Day 90 → auto-ReNurture |
| Inactivity (existing) | post-PoC deals | Yes | last_activity_at | Day 83 warn, day 90 cancel |
The portal already had the inactivity ladder, and its cadence is unchanged. What changed is which rows it sees.
The two clocks measure genuinely different things. A partner who touches a deal weekly for a year never trips the inactivity clock — and that stalled pipeline is exactly what the 90-day rule exists to surface. So the absolute clock does not reset on activity, by design.
Clock ownership is a partition, not two overlapping filters. A deal is governed by the absolute clock before it reaches PoC, by the inactivity ladder after, and by neither once it is parked or closed. isGovernedByPocClock() and isGovernedByInactivityClock() in lib/poc-clock.ts are mutually exclusive by construction, and the suite asserts that across the full grid of stage × status × stamp.
Without that partition a deal registered 90 days ago and silent for 90 days would be auto-cancelled by stage 1 and parked by stage 4 on the same night, emailing its partner two contradictory outcomes.
The guard is applied on both sides — the pure predicates in lib/lifecycle-sweep.ts and the SQL in lib/lifecycle-sweep-sql.ts — because that module's whole discipline is that neither may drift from the other.
| Column | Meaning |
|---|---|
poc_clock_started_at | Anchor of the absolute clock. Written only at registration and on revival. |
poc_reached_at | Write-once. Stamped on first entry to PoC, never cleared. |
renurtured_at | When the sweep parked the deal. |
poc_outcome | "success" / "fail", null until a PoC concludes. |
poc_reached_at is checked before the stage when deciding clock ownership. A deal that reached PoC and was later moved back to Workshop & Demo has still escaped the pre-PoC phase; restarting its absolute clock on a backwards step would let it be parked for a window it already completed — and re-earn a reward it was already paid.
/api/cron/lead-lifecycle-sweep (02:00 UTC daily) gains an auto-ReNurture stage:
registered >= 90 days ago AND still short of PoC AND live
→ portal_status = "renurture", renurtured_at = now
→ release the customer registration lock
→ record a stage transition (reason: system.renurture)
→ email the partner ("moved to re-nurture", never "cancelled")
It needs no "already fired" stamp of its own, unlike the day-83 warning: parking sets portal_status to renurture, which the candidate predicate excludes, so the state change is the idempotence guard.
It narrows on poc_clock_started_at, not last_activity_at, and inherits the existing MAX_ROWS_PER_STAGE cap and the run's shared send deadline — it does not add an unbounded stage to a function already at its time budget.
ReNurture is the one re-entrant state. Moving a parked deal forward revives it:
portal_status → registered, renurtured_at → nullpoc_clock_started_at → now (a fresh 90 days)A Salesforce stage change on a parked deal revives it the same way, re-anchoring the clock. It deliberately does not re-take the lock: seizing a customer back on a Salesforce echo would overturn another partner's claim with nobody deciding to.
"No Duplicate copy of any DEAL for 90 Days Cycle"
The previous guard was scoped eq(clerkOrgId, session.clerkOrgId) — same partner only — and had no time bound. It stopped a partner registering their own customer twice (a hygiene problem) and did nothing about two different partners claiming the same account (a commission dispute).
The old guard matched company name as an exact string, so Acme, Acme Inc and Acme, Inc. were three customers. That is a speed bump, not a lock.
A customer is now keyed on the email domain where there is a usable corporate one — the one identifier a partner cannot restyle — and on a heavily normalised company name otherwise:
| Input | Key |
|---|---|
ops@acme.com + "Acme Corp" | d:acme.com |
anyone@acme.com + "Acme Ltd" | d:acme.com (same customer) |
someone@gmail.com + "Acme, Inc." | c:acme |
Free-mail domains (gmail.com, outlook.com, …) fall back to name-only matching. Keying on them would be catastrophic: one customer contact using Gmail would lock every other partner out of every customer whose contact also used Gmail. The fallback's failure mode is a missed lock; the alternative's is a portal-wide outage of deal registration.
Select-then-insert is a race, and the case it loses is exactly the one the feature exists to decide — two partners registering the same customer seconds apart. Both selects find nothing, both insert, and the portal has promised exclusivity to two people.
So acquireLock() relies on the UNIQUE constraint on customer_key and lets the database arbitrate, with the staleness test folded into the ON CONFLICT ... WHERE clause so there is no window between deciding a lock is stale and taking it.
The lock is taken before the insert, against an id generated up front, so the lock and the row cannot exist independently. A failed insert releases it — otherwise a dead registration would hold the customer for 90 days.
Held while the deal is live and now < acquired_at + 90 days. Released on cancel, rejection, or ReNurture. A deal that reaches PoC keeps its lock while it stays live — a partner who has done real work does not lose the account on a technicality.
Expired locks are released, not deleted: "who held this account, and when" is the question a channel-conflict adjudication actually asks.
Telling Partner B "this customer is already registered" tells them a competitor is working that account. The portal would be leaking pipeline intelligence between partners who compete.
So:
BLOCKED_REGISTRATION_MESSAGE, which says nothing: it does not confirm the customer exists, name the holder, or give an expiry. A test asserts it leaks none of those.activity_log as opportunity.registration_blocked, and to an internal-only deal.registration_blocked notification.Admins get the picture; partners never do.
"Any Partner that successfully completes the 90 Days Cycle → Reward Alert to Portal S.Admins"
When a deal's stage first becomes PoC and poc_reached_at - poc_clock_started_at <= 90 days:
partner_rewards row opens as pending, with days_to_poc recorded.deal.reward_earned fires to super admins only.Day 90 counts as completion — "completes the 90 Days Cycle" means landing on it, not missing it.
The portal records rewards; it never disburses them. Status moves pending → approved → paid, or pending → rejected, at /admin/rewards. paid and rejected are terminal: reversing a payment is a finance operation with its own trail, not a dropdown.
The partner is deliberately not notified. Telling a partner they have "earned" something Matters.AI has not agreed to pay is a promise the portal cannot keep. See PENDING_TASKS §25 to change that.
Two guards. poc_reached_at is write-once, so re-entering PoC cannot re-trigger. partner_rewards.opportunity_id is UNIQUE, which survives a concurrent double-submit — a duplicate reward is a payout error, not a cosmetic one. The alert fires only when the insert actually opened a row, so a losing race produces no second alert.
opportunity_stage_transitions is append-only: nothing updates or deletes. It records opportunity, org, from-stage, to-stage, actor (null = system), reason, timestamp.
Reasons: partner, admin, system.renurture, system.salesforce, migration.
It is deliberately separate from activity_log, which keeps its own row per transition. The two serve different readers — activity_log is the operator audit trail across every entity, this is a typed, queryable funnel history for one. Collapsing them would mean deriving analytics from a free-text summary and a jsonb blob.
Writes are best-effort and never awaited on the critical path: a stage change is the business fact, and a failure to annotate it must not surface as a failed submission.
Derived, never stored — differenced from consecutive transitions by lib/time-in-stage.ts, so the summary cannot drift from the history it summarises.
Repeat visits stay separate: two three-day visits to PoC and one six-day visit are different stories about a deal, and collapsing them hides the regression that caused the second visit.
/admin/funnel, internal only.
The brief was "whatever can be tracked or used in any KPI", which is not a specification — built to that brief a dashboard fills with numbers that are cheap to compute and answer no question anyone asked. So the metric set is small, and each tile is tied to a decision:
| Metric | Decision it informs |
|---|---|
| Live deals per stage | Where is pipeline sitting right now |
| PoC conversion rate | Is partner-sourced pipeline real |
| Completed the cycle | How many converted in time |
| Median days to PoC | How long the cycle genuinely takes |
| At risk (≤14 days left) | Who needs chasing this week |
| Parked in re-nurture | How much pipeline the rule is reclaiming |
| By partner | Which partners to invest in |
Medians, never means — deal cycles are strongly right-skewed and a handful of year-long deals would drag a mean far above anything recognisable as typical.
The per-partner table ranks by completed cycles, not registration volume: volume flatters a partner who registers everything and converts nothing.
"No data" renders as an em dash, never as 0%. On a page someone allocates investment from, those must not look alike.
The at-risk list is sorted most-urgent-first because it is a worklist, not a report.
| Event | Audience | Why |
|---|---|---|
deal.reward_earned | internal | A commercial decision, not a promise to the partner |
deal.renurtured | both | The partner's registration lapsed; internal needs the pipeline signal |
deal.registration_blocked | internal | Surfacing it to either partner would disclose the other is working the account |
The deal detail page shows a deadline banner above the stage control — the context for the decision the partner is about to make with it. Its wording lives in lib/poc-deadline-copy.ts so it is unit-testable, because the copy matters commercially:
| State | Tone | Says |
|---|---|---|
| > 30 days left | neutral | "N days to reach PoC" |
| ≤ 30 days | warning | escalated |
| ≤ 7 days | danger | escalated |
| Reached PoC | done | "PoC reached in N days" — and only claims the cycle was completed if it was |
| Parked | neutral | "Move this deal forward to pick it back up — it starts a fresh 90 days" |
The parked copy never says "cancelled", and a test asserts it. The deal is revivable, and telling a partner otherwise would cost Matters.AI pipeline that is still live. For the same reason the timeline labels renurture as "Re-nurture", not "Closed".
The old vocabulary maps as follows, applied once:
| Old | New |
|---|---|
| Prospecting | Pipeline |
| Qualification | Pipeline |
| Discovery | Workshop & Demo |
| POC/Pilot | PoC |
| Proposal | Negotiation |
| Negotiation | Negotiation |
Lossy by design — three old values collapse to two. mapLegacyStage() is total: deal_stage is free text fed by the form, the CSV seed and Salesforce callouts, so an unknown value lands at Pipeline (recoverable, and governed by the stricter clock) rather than throwing mid-migration and leaving the table half-converted. It is also idempotent over the new vocabulary, so it is safe to re-run.
Deploy steps, including the SQL backfill, are in apps/partners/PENDING_TASKS.md §23. The migration must be applied before deploy or opportunity pages will 500.
Salesforce has no verified custom field for the stage vocabulary, PoC outcome or ReNurture, and lib/sf/writeback.ts carries a standing rule that a field is never invented there — an unknown field 400s the whole request and the error is swallowed by design, so inventing one loses the Lead entirely rather than just the field.
Today the stage at registration and the 90-day deadline go into the Lead Description as free text; later lifecycle changes are not pushed. The portal is the system of record for lifecycle state. PENDING_TASKS §24 lists the fields that would enable real two-way sync.