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.

Author pk675 PR dealops#5770 Branch pk/admin_product_locked_bug Status RFC · Draft Door Two-way Files 2 (+228 / -0) Schema No change

The bug

An admin edit to a product (price, name, description) silently mutates already-closed deals. The view changes; the contract of record drifts.

The rule

A catalog change is reflected only in draft quotes, never in committed ones — for both pricing numbers and product metadata.

The mechanism

Re-pin pricingSpecId / dataSheetId on every draft save. Committed quotes can't save, so the pin freezes on its own.

What this PR is

RFC + new vocabulary in CONTEXT.md. No code. No schema migration. Two-way door — reverting the design restores prior behavior.

PricingQuote (row)
PricingSpec / DataSheet (catalog)
Frozen / committed
Live / draft
Today's leak

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.

Channel 1 — leaks Pricing (backend)

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.

Channel 2 — leaks Metadata (frontend)

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.

v1 froze both. It deep-copied the products blob (metadata) and FK-pinned curves (pricing). v2 was built to freeze (FK pins + copy-on-write), but the read paths ignore the pins they carry. This RFC enforces the freeze v2 already has the schema for.

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
Draft quote

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.

Committed quote

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?

SignalWhat it misses / over-firesVerdict
isLocked onlyMisses 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
submittedToCrmAtCRM writeback fires on open opps mid-negotiation. The quote is still a draft.Over
isClosed || isLockedClose 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

1 · Re-pin on every draft save

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).

2 · Gate the GET recompute

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.

3 · FE loads the pinned spec

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.

4 · Scripts skip committed rows

refreshPricingQuotes filters out committed quotes by default. An explicit --include-committed opt-in (logged) covers deliberate migrations / corruption fixes.

5 · Stay surgical

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).

Scenariov1v2 (after change)Match?
Draft — view prices / productsLive (mutable PricingFlow)Recomputes vs latest specLive
Admin edits catalog, draft openReflectedReflected
Submit / commitPricingFlowSnapshot written → frozenPin frozen (last save)Frozen
Committed — view numbersSnapshot read verbatimStored outputFrozen
Committed — view name / descDeep-copied in snapshotResolved vs pinned specFrozen
Admin edits catalog, committedNo effectNo effectFixed
Recall → edit → resubmitNew snapshotPin advances on each saveRe-freezes
Why v1 never hit this bug: v1 had no in-app catalog editing — no product edit/delete endpoints, curves only appended. A snapshot couldn't drift because the catalog couldn't change. v2 added admin catalog editing without enforcing the freeze the schema was built for.

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:

LIST_RATE

Percent component

LIST_FLAT_FEE

Currency component

PROCESSING_FEE

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.

The two pins already cover composite — no special handling. Component values live in the pinned DataSheet; the composite shape lives in the pinned PricingSpec. Pinning the whole datasheet means all N components freeze atomically. A committed quote can't show a frozen rate beside a live flat-fee.
Why Channel 2 is load-bearing for Xendit: Xendit is NB-only. NB returns stored 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

Recall = the only un-freeze

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.

Amendments & renewals freeze identically

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

9. Diff at a glance

FileChangeWhat'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

Ship together

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.

Acceptance tests
  1. Close NB → rename product → quote renders old name (metadata).
  2. Lock amendment → edit price → GET output unchanged (numbers).
  3. Draft → edit product → reflects it (both channels).
  4. refreshPricingQuotes on a mix → committed untouched.
  5. Each draft save sets pricingSpecId = current-latest; committed pin stops moving.
  6. Recall on open opp → live; re-freezes on next commit. Rejected / approved stay frozen.

11. Risks & open questions

Writeback CRM mapping follows latest. Deal numbers from stored 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.
Draft amendment / renewal reprices at latest — for composite orgs, the rate card moves. By design, a draft amendment / renewal resolves against latest. For Xendit, that means a draft renewal reprices the new term at current rates and a draft amendment prices its delta at current rates. Two sub-questions: (a) should a renewal auto-adopt current rates, or hold the prior contract's? (b) should an amendment's continuing lines be repriced, or held frozen against the prior contract while only the delta prices at latest?
Hard-deleted product on a draft amendment / renewal. 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.