Engagement Contract Model: team docs + pre-rollout review
A documentation-only PR landing seven team-facing docs for the Account→Engagement→Contract hierarchy alongside a severity-ranked RFC of findings ahead of enabling the system for Langchain's main org.
docs/engagement-contract-model/ describing the system as-is — data model, lifecycle, renewal pipeline, SF writeback, org-agnostic vs Langchain-specific split, and the migration/onboarding flow.
rfcs/2026-06-09-…rollout-review.md with 14 severity-ranked findings (4 🔴, 4 🟠, 3 🟡, plus minor) and a 9-item pre-rollout checklist for Langchain.
<br/> line breaks. All TypeScript/Prisma/listener behavior is described, not modified.
1. Why this exists
The engagement contract model adds a real three-level customer hierarchy on top of the legacy flat DealGroup table: Account → Engagement → Contract. It also introduces an automated renewal pipeline: when a contract's opportunity goes Closed Won, a PENDING successor contract is scaffolded with a draft quote seeded from the prior contract's signed state.
The system is gated behind three flags and has been incubating for months. Langchain is the first production user. This PR ships the two artifacts the team needs before flipping switches:
2. The seven team docs
| Doc | Lines | Covers |
|---|---|---|
README.md | 87 | Big-picture overview, the three flags, glossary, system map |
01-data-model.md | 136 | Account/Engagement/Contract entities, statuses, DB invariants, worked LangCo example |
02-contract-lifecycle.md | 82 | CRM enrichment → hydration decider → EngagementContractService |
03-renewal-pipeline.md | 124 | Closed-Won scaffolding, seed derivation (state as-of date), reseed triggers, walker cutover |
04-salesforce-writeback.md | 94 | Deal_Group__c sync: triggers, field derivations, status mapping, chain linkage |
05-langchain-vs-general.md | 72 | Org-agnostic core vs Langchain-specific adapters |
06-migration-and-onboarding.md | 74 | The 3-pass backfill, flag flip order, new-customer onboarding checklist |
The hierarchy at a glance
Account — persistent customer identity. Survives churn and win-back.
crmAccountId deliberately NOT unique (NULL-heavy for orgs without SF Account); enforced at service layer.
Engagement — one continuous commercial relationship.
ACTIVE or CHURNED. A win-back opens a new Engagement under the same Account.
Contract — a
DealGroup with type=CONTRACT. One signed term. Has contractType (NEW_BUSINESS / RENEWAL), engagementSequence (1-based), status (PENDING / ACTIVE / CLOSED / ARCHIVED).
Memberships — sequence 1 is the head (NB or renewal opp), sequence 2+ are amendments of that term. The chain walker starts here.
The three feature flags
isEngagementContractModelEnabled
EngagementContractService (lazy Account/Engagement, sequence allocation, invariants). Also the manual dealGroup.create path.
useEngagementRenewalChain
canReseedFromPriorContract signal.
isLangchainDealGroupWritebackEnabled
Deal_Group__c sync listener and the scaffold reactor's best-effort chain sync. Decoupled by design — the model runs without any SF mirror.
3. How the system reads (a tour through the docs)
Pick a tab to see how that doc frames its slice of the system.
01 — Data model
All engagement fields on DealGroup are nullable, so legacy rows remain valid and legacy read paths keep working. The model layers on top, not over.
The invariants that matter:
- At most one ACTIVE Contract per Engagement — partial unique index
DealGroup_active_contract_per_engagementscoped to ACTIVE only, so a PENDING renewal can coexist with the ACTIVE prior. - Unique
(engagementId, engagementSequence)— concurrent allocation is read-max-then-insert with P2002 retry; the unique index is the backstop. - Head-type matches contract-type — service-layer check: seq-1 must be NEW_BUSINESS for an NB contract, RENEWAL for a renewal; seq>1 must be AMENDMENT.
The PENDING→ACTIVE transition ("supersession") is a separate workstream. Service primitives exist (createRenewalContract, closeContractDealGroup) but no production path calls them yet — flagged in F7 of the review.
02 — Contract lifecycle
Contracts are created and grown almost entirely by CRM-sync enrichment. The decider is org-agnostic: it consumes a canonical OpportunityType enum produced by mapCrmTypeToOpportunityType from whatever CRM-type vocabulary the org uses.
With the model flag on, enrichment runs decideEngagementHydration (a pure function). With it off, the legacy decider runs and produces flat legacy DealGroups exactly as before. The flag check is the single boundary — neither path branches internally.
Three intents:
- NEW_BUSINESS → find-or-create Account, find-or-create ACTIVE Engagement, create ACTIVE NB Contract at seq=1, attach opp as head.
- AMENDMENT → resolve prior via
custom.lastOpportunityId, attach as next membership. - RENEWAL → create PENDING RENEWAL Contract at seq+1 (prior stays ACTIVE). Account-fallback when the pointer is missing.
Enrichment is fail-soft: any error is logged as a warning and never fails the opportunity sync — relevant to finding F3 (silent stranding).
03 — Renewal pipeline
The pipeline keeps a shadow renewal ahead of every active contract: a PENDING Contract whose draft quote always reflects the prior contract's accumulated signed state. Three cooperating mechanisms maintain it.
ensureRenewalForClosedWonContract creates (or refreshes) the PENDING successor. Two signal sources: Opportunity webhook + Deal_Group__c webhook.
buildEngagementRenewalSeed replays the prior contract's chain (head + Closed-Won amendments) and resolves state as-of the renewal start date. Base quote = the prior contract's head quote.
Walker cutover safety: both legacy + engagement walkers run in parallel for every renewal quote create, with a structured diff log on disagreement. An org can flip the chain flag after a clean-diff window.
04 — Salesforce writeback
The crucial semantic difference:
Idempotency model: natural key is Deal_Group_Id__c = our DealGroup.id; UPDATE only issued when the desired payload differs from live SF values — re-delivered Closed Wons produce no-ops, our own writes don't ping-pong through inbound webhooks.
Status mapping: LC's Status__c picklist has no "Pending", so PENDING and ACTIVE both map to Active — the future Group_Effective_Date__c communicates the "hasn't started yet" state.
05 — Langchain vs. general
The model was designed org-agnostic from the start. This doc maps the boundary explicitly because onboarding the next customer means knowing what's free and what needs a per-org adapter.
EngagementContractService, the decider (enum-driven), the type-mapping table, the chain walker (structural, not string-based), the seed builder (config-driven), the scaffold orchestrator, the backfill, the flags, the client signal.
LangchainOpportunityEnrichment (I/O shell), findRenewalOpportunityId (LC SOQL), the entire Deal_Group__c writeback + spec, listener org-name allowlists, webhook-object constants, naming strings.
The two engagement flags gate core behavior (org-agnostic switches). The writeback flag gates an adapter. They're deliberately decoupled — an org can run the model with no SF mirror at all.
06 — Migration & onboarding
The backfill runs three independent, idempotent passes per org:
- opportunity-type — populates
OpportunityV2.opportunityTypefrom CRM data via the shared mapping. - engagement — per existing DealGroup, creates Account + Engagement (1:1:1), sets
type=CONTRACT,engagementSequence=1,contractType. - orphan-opportunity — opps in no DealGroup, reconciled per CRM account in closeDate order.
Flag flip order matters: chain → model → writeback. The write path assumes the read path can seed renewals it creates. Code comment is the only enforcement today — finding F4.
Onboarding a new customer is a 10-item checklist (CRM vocabulary, enrichment, renewal-opp source, optional SF mirror, allowlists, seed config, backfill, flags, verification).
4. The pre-rollout review (the RFC)
The second artifact is rfcs/2026-06-09-engagement-contract-model-rollout-review.md — kept deliberately separate so disagreement over severity calls doesn't bleed into the reference docs. 14 findings, ranked.
The four 🔴 blockers, in detail
Duplicate PENDING renewal race — three writers, no DB constraint
The PENDING renewal successor can be created by three independent code paths: scaffold reactor, Deal_Group__c reconcile reactor, CRM-sync enrichment. All run on independent queues. All dedupe via read-then-write.
The (engagementId, engagementSequence) unique index does not reject a second PENDING — createContractDealGroup's P2002 retry loop re-reads the max and lands the loser at seq+2. The single-active partial index is ACTIVE-scoped and never applies.
Aggravator: enrichment's dedupe is head-dependent (findPendingRenewalSuccessorOpp), so a half-finished scaffold (contract row created, head not yet attached) is invisible to it. The scaffold's own orphan-reuse is head-independent. Asymmetric.
Fix: Postgres advisory lock keyed on prior DealGroup id inside createPendingRenewalContract, with successor re-check inside the lock. Switch enrichment to the head-independent lookup.
Any SF edit to the prior's Deal_Group__c row wholesale-overwrites the renewal draft
renewalReconcileFromDealGroupReactor has no field/stage gate. Every Deal_Group__c UPDATE webhook for an ACTIVE contract reaches ensureRenewalForClosedWonContract → refresh branch → reseedRenewalQuote.
findRenewalDraftQuote picks primary-else-most-recent — so a quote the rep built later (or marked primary) is selected over the scaffold draft and overwritten wholesale.
Fix: gate the reconcile reactor's refresh on the renewal link actually changing (the payload already carries it); and/or restrict the refresh target to the scaffold-created draft.
Closed Lost is handled nowhere
NB contracts are created at sync time at any stage — the decider has no stage check. A pipeline-stage "New to Langchain" opp opens an ACTIVE Contract immediately. If the deal is lost, the contract stays ACTIVE forever.
A dead ACTIVE contract permanently blocks future NB contracts for the account: assertNoActiveContractForEngagement throws, enrichment swallows to logWarning. The new opp is silently stranded with no DealGroup on every re-sync.
Fix: gate NB contract creation on Closed Won, and/or build a Closed-Lost reactor that closes/archives the contract.
Model ON + chain OFF actively corrupts renewal quotes
Renewal contract creation is gated only on isEngagementContractModelEnabled. Renewal seeding only on useEngagementRenewalChain. In the intermediate state, renewal opps become sole sequence-1 heads, the legacy walker finds no prior, and renewal quotes seed with catalog defaults — visibly wrong, not a no-op.
Enforced only by a code comment. Also: reactor flag evals use sentinel users ('system', ''). If PostHog targets by user properties, reactors and tRPC requests split-brain.
Rule: target by org ID only; flip chain ≤ model; never run model-only.
Pre-rollout checklist (9 items)
| # | Action | Ties to |
|---|---|---|
| 1 | Run duplicate-crmAccountId audit SQL on LC prod; merge dupes. | F5 |
| 2 | Land advisory-lock + head-independent-dedupe fix (or accept race + monitor). | F1 |
| 3 | Gate reconcile reactor's refresh on renewal-link change, or restrict reseed target. | F2 |
| 4 | Decide the Closed-Lost story; audit LC data for accounts with 2+ open NB opps. | F3 |
| 5 | PostHog: all three flags org-ID-only; flip order chain → model → writeback. | F4 |
| 6 | Confirm LC automation: when does it set Opportunity_Replaced_By__c vs Closed-Won webhook? | F8 |
| 7 | Fix record_type on eagerly-created renewal opps (or normalize exclude-filter matching). | F6 |
| 8 | Comms to LC reps: the PENDING draft quote is system-maintained and will be overwritten. | F2, F14 |
| 9 | Monitor walker diff, P2002 retries, addOpportunityToContract paged errors, enrichment warn strands. | — |
5. What this PR does not change
- Zero runtime behavior. Docs-only. No TS, no Prisma, no listeners, no flags.
- No new flags or PostHog config. The three flags described already exist; their defaults and allowlists are unchanged.
- No code changes to address the findings. The RFC is a draft for the team to debate severity calls and the checklist before acting. The fixes for F1–F4 are described as proposed, not landed.
- No design changes to the model. All docs describe the system as it currently exists in
main. - No new test coverage. Test plan limited to: docs-only, Prettier ran, Mermaid syntax is GitHub-safe.
- The supersession workstream is not started. F7 documents the dead-end paths (
createRenewalContract/closeContractDealGroupuncalled, Order Form close-out unreachable) — observed, not fixed.
6. Open questions raised for the team
- Is F3 (Closed Lost) really pre-rollout, or is "monitor + manual cleanup" acceptable for the LC main org given how few NB opps are in flight?
- F2's blast radius depends on how often LC admins edit
Deal_Group__crecords outside the renewal-link path. Is gating the reactor sufficient, or do we also need to tag the scaffold draft? - F1's advisory-lock fix vs. accept-and-monitor — the loud monitoring query may be cheaper.
- Does LC automation set
Opportunity_Replaced_By__cbefore or after the Closed-Won webhook fires? - Does LC reliably write
Renewal_Opportunity__conDeal_Group__c? - Should we populate
RENEWAL_LINK_WEBHOOK_FIELD(currently null/inert) to arm the re-trigger as a safety net?
7. Reviewer guidance
main. Mermaid renders correctly on GitHub; verify any diagram you're skeptical of.