Freeze committed quotes against catalog edits
RFC: when an admin edits the catalog, draft quotes update — committed quotes (closed / in-approval / DocuSign-locked) must stop drifting.
An admin edit to a product (price, name, description) silently mutates already-closed deals. The view changes; the contract of record drifts.
A catalog change is reflected only in draft quotes, never in committed ones — for both pricing numbers and product metadata.
Re-pin pricingSpecId / dataSheetId on every draft save. Committed quotes can't save, so the pin freezes on its own.
RFC + new vocabulary in CONTEXT.md. No code. No schema migration. Two-way door — reverting the design restores prior behavior.
1. Why this exists
A PricingQuote row already pins one PricingSpec version and one DataSheet version via non-null FKs. Both tables are copy-on-write — admin edits .create() a bumped version; old versions are immutable. The schema is built for snapshotting.
But nothing on the read path actually honors the pins. Both read channels go to the org's latest catalog instead, so an admin edit reaches into already-closed deals.
buildSummaryInput loads spec via findMostRecent (latest). The pricingSpecId arg is destructured and never used. Recompute fires on GET for amendment / renewal / preview.
scripts/refreshPricingQuotes is a persisting vector — recomputes vs latest, writes back, no isLocked filter.
A line item stores only productSpecId. The FE resolves name / description / category by joining against pricingSpecs.get — the org's latest — unconditionally.
A rename changes the display on every quote, committed or not. NB included.
2. Today's leak, step by step
An admin opens the catalog and edits a product. Watch where the change lands.
3. The freeze invariant
One predicate, used everywhere:
isCommitted ⟺ isClosed || isLocked
// isLocked = inFlightApprovalGraphStateId != null || isDocuSignLocked
Open opp, no approval / DocuSign lock. Resolves spec-derived data against the org's latest catalog. Recompute fires on read. Pin re-stamps on every save.
isClosed || isLocked. Numbers come from stored output. Metadata joins against the pinned spec. Pin doesn't move because the row can't save.
Why not isLocked alone, or submittedToCrmAt?
| Signal | What it misses / over-fires | Verdict |
|---|---|---|
isLocked only | Misses closed deals that auto-approved or had no rules triggered — they never got an in-flight approval, so they're closed but not "locked". | Under |
submittedToCrmAt | CRM writeback fires on open opps mid-negotiation. The quote is still a draft. | Over |
isClosed || isLocked | Close is the commitment that makes the quote a contract of record. Locks cover in-flight and DocuSigned states. | Right |
4. The fix, in five moves
Each update() with refreshOutput writes pricingSpecId / dataSheetId to current-latest. Committed rows can't save → pin freezes at the last pre-commit edit.
Guarantees the pin covers every product on the quote (you can't add one without saving).
In get.ts (amendment / renewal / preview), branch on !isCommitted. Committed → return stored output verbatim. Fallback for incomplete output: recompute once against the pinned spec, display-only, never latest.
Broaden the update.ts save-guard from isLocked to isCommitted defensively.
Centralize: isCommitted ? pricingSpecs.getById(quote.pricingSpecId) : pricingSpecs.get(). Route every quote-rendering surface — detail, approval review, PDF, list — through one hook.
Both getById + client hook already exist; just unused here.
refreshPricingQuotes filters out committed quotes by default. An explicit --include-committed opt-in (logged) covers deliberate migrations / corruption fixes.
Catalog-independent read-fixes (the endDate derivations in get.ts:82-190) keep running. "Frozen" means frozen against the catalog, not a bit-for-bit row replay.
5. v1 vs v2 — same behavior, lighter mechanism
After this change, v2's observable behavior matches v1; v2's mechanism stays lighter (FK pins + copy-on-write vs. v1's deep-copied snapshot blob).
| Scenario | v1 | v2 (after change) | Match? |
|---|---|---|---|
| Draft — view prices / products | Live (mutable PricingFlow) | Recomputes vs latest spec | Live |
| Admin edits catalog, draft open | Reflected | Reflected | ✓ |
| Submit / commit | PricingFlowSnapshot written → frozen | Pin frozen (last save) | Frozen |
| Committed — view numbers | Snapshot read verbatim | Stored output | Frozen |
| Committed — view name / desc | Deep-copied in snapshot | Resolved vs pinned spec | Frozen |
| Admin edits catalog, committed | No effect | No effect | Fixed |
| Recall → edit → resubmit | New snapshot | Pin advances on each save | Re-freezes |
6. Composite SKUs (Xendit) raise the stakes
A composite product has no single LIST_PRICE. Its price is spread across several datapoints in the DataSheet, keyed by selectorTags.type:
Percent component
Currency component
Currency component
Production Xendit's datasheet is 183 LIST_RATE + 222 LIST_FLAT_FEE + 288 PROCESSING_FEE, zero LIST_PRICE. The local onboarding seed (83×LIST_PRICE) doesn't reflect reality.
output, so Channel 1 is nearly moot. The actual leak is Channel 2 — the FE reads each product's price out of the loaded spec, and quote.input doesn't carry composite component values. For most orgs the FE leak is name / description; for Xendit, the re-joined field is the price. A committed Xendit quote re-renders with a wrong rate card after any admin re-price.
7. Recall, amendments, renewals
The in-flight approval pointer disconnects only on status === RECALLED (PricingQuoteApprovalDbConnector:428,445). Reject / Approve keep it connected → stay frozen.
Recalled + open opp → live draft (pin advances on save). Recalled + closed opp → stays frozen. Re-commits on resubmit.
While drafts, both resolve against latest (correct — the rep is pricing now). Once committed: stop saving → stamp + saved numbers freeze.
A chain (NB → amendment → renewal) becomes a stack of independent frozen snapshots, each pinned to its commit-time edition.
8. What this PR does not change
- No schema change. No new columns, no migration. The FK pins already exist.
- No data backfill. Existing committed quotes resolve against their creation-time pin. That stops future leaks; metadata reflects creation-time (differs from commit-time only if the catalog moved during draft).
- No feature flag. Two-way door — code revert restores prior behavior.
- Draft behavior. Unchanged. Drafts keep recomputing live against the latest catalog.
- Preview. Unlocked + open → still runs live against draft-spec overrides. That's its purpose.
- Writeback (the action). Read-only w.r.t. the quote — reads stored
output, only stampssubmittedToCrmAt. Never bakes a leak into a committed quote. - v1 quote history. Not added. v2 still overwrites a single row on recall → edit → resubmit; approval history stays in
ApprovalGraphState. - Code. This PR is the RFC + new vocabulary in
CONTEXT.md. No implementation in these two files.
9. Diff at a glance
| File | Change | What's in it |
|---|---|---|
CONTEXT.md |
+82 modified | New vocabulary: committed quote, draft quote, copy-on-write spec, catalog freeze invariant, composite list price. Two new relationship bullets on pinning. Decisions-log entries dated 2026-06-02. |
rfcs/2026-06-02-locked-quote-catalog-freeze.md |
+146 added | The RFC itself. Plain-terms summary; today's state per-channel; v1↔v2 behavior + mechanism tables; five-step proposal; composite section; rollout + tests; open questions. |
10. Rollout & tests
The get.ts / update.ts gate, the FE pinned-spec hook, the re-pin write, and the refreshPricingQuotes filter — one coordinated change. Forward-only writes always set a valid spec version.
- Close NB → rename product → quote renders old name (metadata).
- Lock amendment → edit price → GET
outputunchanged (numbers). - Draft → edit product → reflects it (both channels).
refreshPricingQuoteson a mix → committed untouched.- Each draft save sets
pricingSpecId= current-latest; committed pin stops moving. - Recall on open opp → live; re-freezes on next commit. Rejected / approved stay frozen.
11. Risks & open questions
output are frozen, but writeback reads the latest spec for product→CRM-ID mapping. A re-push after a mapping change targets the new mapping. Leaning acceptable ("how to map to current CRM", not deal economics); pinning a separate WritebackSpec would need new infra.
deleteProduct hard-deletes from the spec. Committed quotes pinned to the old version stay intact — but a draft amendment / renewal carries the product forward and resolves against latest, where it no longer exists → productSpecs[productSpecId] is undefined → engine reads calculationSpec non-optionally at several sites → 500. Pre-existing, but the freeze sharpens the asymmetry (pinned safe, latest exposed). Options: block deletion when referenced by an open amendment, resolve carried-over lines against the prior pin, or fail soft with a visible "no longer in catalog" line state.