---
title: "Immutable quote versions: multi-round negotiation without overwriting history"
description: "A sent quote must never change. How to version B2B quotes: copy-on-write snapshots, counters as versions, optimistic locking and the stale-accept guard."
url: "https://www.quotway.com/blog/immutable-quote-versions"
type: "blog post"
category: "Engineering"
published: "2026-10-15"
verified: "2026-10-15"
shopify_api_version: "2026-10"
audience: "Developers and agencies building or reviewing quote negotiation on Shopify or any commerce platform"
scope: "Versioning B2B quotes at Shopify API 2026-10: why editing a sent quote in place breaks documents, approvals and acceptance; what Shopify's draft orders and order edits give you; the working set, draft and version layers; what a version must hold; copy-on-write creation; counter-offers as versions; the send lock; version numbering under concurrency; optimistic versus pessimistic locking; the stale-acceptance guard; superseding approvals; catalog changes; partial acceptance; diffs; and temporal tables, type 2 dimensions and event sourcing compared"
locale: "en"
source: "QuotWay - B2B Quote & Negotiation App for Shopify"
---

# Immutable quote versions: multi-round negotiation without overwriting history

A quote that has been sent is a commercial document: the buyer has a PDF, an approver signed off on its numbers, and an acceptance may already be on its way. If the row behind it can still be edited, all three can quietly stop matching. The fix is a data model where a sent offer never changes - every change becomes a new, numbered version - plus the concurrency rules that keep two people, or a person and a stale browser tab, from acting on different versions at once.

This page is that design, with simplified code: what a version must hold, how to create one by copy-on-write, why a counter-offer is a version and not a patch, how to number versions under concurrency, optimistic versus pessimistic locking for a negotiation that runs for days, and the guard that stops a buyer accepting an offer that has already been replaced. Where QuotWay is the example, it is described as it works today. [The approval audit log](/blog/pricing-approval-audit-log) covers the record a decision leaves; this page covers the thing being decided on.

Shopify and database facts below were checked against their own documentation on 15 October 2026, at Shopify API version 2026-10 and PostgreSQL 18; sources are at the end.

## What editing a quote in place breaks
Picture one quote row with its lines, edited each time the deal moves. Four things go wrong:

- **The document and the data disagree.** The buyer's PDF says one price; the row now says another. When someone asks what was offered on 3 March, the database can't answer.
- **Approvals point at numbers that moved.** A sign-off on an 18 percent discount means nothing if the lines it approved can be changed after the approval.
- **Acceptance races the edit.** A buyer looking at last week's screen clicks accept a second after the seller lowered a quantity. Which offer did they accept?
- **Rounds disappear.** A counter-offer that overwrites the seller's prices leaves no trace of what either side proposed, which is the whole story of a negotiation.

Every one of these is a question about *which* offer, and an in-place row can only ever answer "the current one". That is why [the quote architecture](/blog/shopify-b2b-quote-architecture) gives proposal versions to the quote layer, not to Shopify.

## What Shopify gives you
Shopify has no quote object, so a quote layer on Shopify either uses draft orders as its quotes or keeps its own. Draft orders are edited in place: `draftOrderUpdate` "updates a draft order", and the `DraftOrder` object carries an `events` list and an `updatedAt` timestamp, but no history of its contents. Two documented details matter here:

- "If a checkout has been started for a draft order, any update to the draft will unlink the checkout."
- "Draft orders created on or after April 1, 2025 will be automatically purged after one year of inactivity."

So a draft order is a fine *output* of a negotiation and a poor record of one. [Draft order vs quote](/blog/shopify-draft-order-vs-quote) goes through the state machine a quote needs and a draft order lacks.

Shopify's own design for changing a placed order is instructive: an order edit is staged. `orderEditBegin` returns a calculated order showing the changes applied, and "the session tracks all staged changes until you commit or abandon them." The committed order is never half-edited. A quote layer wants the same property for every offer it sends.

## Three layers: working set, draft, versions
| Layer | Mutable? | What it is for |
| --- | --- | --- |
| **Working set** (live lines) | Yes, until the next send | The lines the seller is pricing now - added, removed, re-quantified |
| **Draft** | Yes; discarded after send | The editor's unsaved proposal: prices, discount, shipping, terms, expiry, message |
| **Versions** | Never edited | One complete snapshot per offer sent and per counter received, numbered 1, 2, 3 … |

The working set is allowed to change because nobody has seen it. The moment an offer leaves, it becomes a version, and the working set catches up to it. A version is only ever written once.

## What a version must hold
A version has to stand on its own: someone reading it in a year must not need the working set, the catalog or the product to understand it.

| Part | Fields | Why it is copied, not referenced |
| --- | --- | --- |
| **Identity** | Quote, version number, who made it (seller, buyer, system), when | Rounds are ordered by number, not by timestamp |
| **Each line** | Product and variant ids, title and variant title as shown, quantity, four prices: the catalog price when the line entered the quote, the catalog baseline (for B2B, the company location's price), the buyer's requested price, the offered price; the line total | Products get renamed and repriced; the offer must not |
| **Line visibility** | Whether the buyer sees the line's price; a price-hidden line shows "Included" and must be 0.00 | Buyer totals come from the stored figure, never summed from the displayed rows, so a hidden line with a price could be revealed by subtraction ([why totals are stored](/blog/never-sum-the-total-from-the-rows)) |
| **Money** | Subtotal; the whole-quote discount as type, input value and resolved amount; shipping amount and whether it is final; tax estimate; totals; currency | A percentage discount resolves differently on a different subtotal |
| **Terms** | Payment terms, deposit percentage, expiry, the message to the buyer | Terms are part of what was offered |

One small decision does a lot of work: a version line keeps a *reference* to the working-set line it came from, deliberately not a foreign key. The working-set line can be deleted later; the version must survive it.

## Creating a version: copy-on-write
A send reads the working set and the draft, writes a new version, and never touches an earlier one. This is how QuotWay creates a seller's version, simplified from production with internal names removed:

```ts
async function createVersion(db: Db, quoteId: string, input: ProposalInput) {
  validate(input); // every line priced, nothing negative, fixed discount <= subtotal,
                   // a price-hidden line must be 0.00 (totals are stored, not summed)
  return db.transaction(async (tx) => {
    // Any approval chain still running on the previous version is superseded.
    await tx.approvalChains.markSuperseded({ quoteId, states: ["PENDING", "IN_PROGRESS"] });

    // The next number comes from an atomic increment on the quote row (see "numbering").
    const { currentVersionNumber: versionNumber, currency } =
      await tx.quotes.increment(quoteId, "currentVersionNumber");

    const live = await tx.lineItems.findMany({ quoteId, ids: input.lines.map((l) => l.lineId) });
    const lines = input.lines.map((u, i) => {
      const src = live.byId(u.lineId);
      const qty = u.quantity ?? src.quantity;
      return {
        sourceLineId: src.id,            // a reference, deliberately not a foreign key
        sortOrder: i,
        productId: src.productId, variantId: src.variantId, title: src.title,
        quantity: qty,
        catalogPrice: src.catalogPrice,  // as captured when the line entered the quote
        catalogBaseline: u.catalogBaseline ?? src.catalogBaseline,
        requestedPrice: src.requestedPrice,
        finalPrice: u.finalPrice,
        lineTotal: u.finalPrice.times(qty),
        buyerVisibility: u.buyerVisibility ?? src.buyerVisibility,
      };
    });
    const subtotal = sum(lines.map((l) => l.lineTotal));
    const discount = resolveDiscount(subtotal, input.discountType, input.discountValue);
    const total = subtotal.minus(discount).plus(input.shipping ?? 0).plus(input.taxEstimate);

    // The working set catches up to the offer...
    await tx.lineItems.updateEach(lines.map((l) => ({ id: l.sourceLineId, finalPrice: l.finalPrice, quantity: l.quantity })));
    // ...and the offer itself is a new row. Nothing here updates an earlier version.
    return tx.versions.create({
      quoteId, versionNumber, origin: "MERCHANT", currency,
      subtotal, discount, discountType: input.discountType, discountValue: input.discountValue,
      shipping: input.shipping, taxEstimate: input.taxEstimate, total,
      paymentTerms: input.paymentTerms, depositPct: input.depositPct, expiresAt: input.expiresAt,
      lines,
    });
  });
}
```

After a version is written, the only later writes in QuotWay are stamps: a marker that a buyer's counter superseded it, and the acceptance record. No code path changes a version's lines or figures. That is a property of the code, not of the database - nothing at the database level would stop an update - so the honest claim is "never edited by the app", not "immutable in storage". [The audit log post](/blog/pricing-approval-audit-log#append-only) shows how to make append-only a database property, with the same reasoning.

## A counter-offer is a version, not a patch
It is tempting to store a counter as a list of changes against the seller's offer. Don't: a patch can only be read by replaying it onto the right base, and the base may itself have changed. A counter is a complete version made by the other side:

- Copy **every** line of the version being countered. Lines the buyer didn't touch carry the seller's prices forward.
- Overwrite only what the buyer may change. In QuotWay that is each line's price and quantity, line notes, a message, and - only when the countered version already has one - the deposit percentage.
- Carry the rest: discount type and value, shipping, tax and terms. Re-resolve a percentage discount against the new subtotal; refuse a counter whose subtotal falls below a fixed discount.
- Ignore a counter on a price-hidden line; it keeps its source price.
- Mark the countered version as superseded by the new number, in the same transaction.

The seller's reply to a counter is the next version again. Nothing limits the number of rounds; the record grows by one snapshot per move, and the latest one is the live offer.

## The send lock
Before an offer is sent, everything is editable. After, nothing is - and the check must be the same everywhere a change can come from: the editor, line edits, automation and the API.

```ts
const EDITABLE = new Set(["SUBMITTED", "IN_REVIEW", "COUNTERED"]);

// The editor, line edits and the send endpoint all gate on the same set.
function assertEditable(status: QuoteStatus) {
  if (!EDITABLE.has(status)) {
    throw new NotEditable(`Quote is ${status}. A sent proposal can't change; the next move is a new version.`);
  }
}

// From PROPOSAL_SENT, every edge belongs to the buyer or the clock:
const FROM_PROPOSAL_SENT = [
  { to: "COUNTERED",          actor: "BUYER" },  // the counter is a new version
  { to: "FULLY_ACCEPTED",     actor: "BUYER" },
  { to: "PARTIALLY_ACCEPTED", actor: "BUYER" },
  { to: "DECLINED",           actor: "BUYER" },
  { to: "EXPIRED",            actor: "SYSTEM" },
];
```

In QuotWay, the API returns `409 not_editable` for the same reason. Look at what that state table leaves out: there is no seller edge out of a sent proposal. A sent offer stays acceptable until the buyer acts or it expires; the seller's next version follows the buyer's counter, a rejection by one of the seller's own approvers, or - after a partial acceptance - re-opening the lines still open.

If your process needs a seller to replace an offer that is already out, design it as a **supersede**, never an edit: a new version is created and the sent one is marked superseded, so an acceptance of the old one fails the guard below. The rest of this design already supports that edge; whether to offer it is a product decision about how much a buyer may rely on an offer once they hold it. QuotWay doesn't offer it today.

## Version numbers under concurrency
Two writers can create a version at the same moment: a seller sending and an automation, or two staff. The numbers must never collide. Two parts make that true:

```ts
// An atomic increment on the parent row inside the version's transaction.
// Two writers serialise on that row and receive N and N+1, never the same number.
const { currentVersionNumber } = await tx.quotes.update({
  where: { id: quoteId },
  data: { currentVersionNumber: { increment: 1 } },   // UPDATE ... SET n = n + 1 RETURNING n
});
// Backed by: UNIQUE (quote_id, version_number)
```

The increment is safe without an explicit lock because of how PostgreSQL's default isolation handles a second `UPDATE` on the same row: "the would-be updater will wait for the first updating transaction to commit or roll back", then the row "is re-evaluated to see if the updated version of the row still matches." The unique constraint is the backstop if anything else ever writes a number.

## Optimistic or pessimistic locking?
| | Optimistic | Pessimistic |
| --- | --- | --- |
| Idea | Detect a conflicting change when you write, and reject it | Take exclusive access first, so no conflict can happen |
| In SQL | `UPDATE … WHERE id = $1 AND updated_at = $seen` - 0 rows means someone got there first | `SELECT … FOR UPDATE`, held until the transaction ends |
| Over HTTP | `If-Match` with an ETag; 412 when the precondition fails, or 409 for a conflict with current state | No standard equivalent |
| Cost | A retry or an error message for the loser | Blocked writers; a lock that must not outlive the transaction |
| Fits | Human think-time: minutes or days between read and write | Short, contended database work inside one request |

A negotiation is the textbook case for optimistic locking. A round can take days, and you cannot hold a database lock across a buyer's lunch, a weekend or an approval - Microsoft's EF Core guide makes the same point, noting that locking for the duration of an edit "requires a transaction to span all the operations." Martin Fowler's optimistic offline lock "prevents conflicts between concurrent business transactions by detecting a conflict and rolling back the transaction"; the pessimistic version allows "only one business transaction at a time."

Two details people get wrong:

- **Optimistic locking does not need a version column.** EF Core lets any column be the concurrency token, HTTP uses ETags, and a status-plus-timestamp condition works. What it needs is a condition in the `WHERE` clause and a check that one row changed. PostgreSQL counts "matched rows whose values did not change" as updated, so check the count, not whether data moved.
- **`SELECT … FOR UPDATE` doesn't stop reads.** Row locks "block only writers and lockers to the same row." A buyer can still load the page; it is the second write that waits.

QuotWay uses both, each where it fits: optimistic conditions on every status change (the quote's `updatedAt` must still be what was read), and the row-level serialisation of the number increment inside a transaction.

## The stale-acceptance guard
The buyer's accept button must name the version it accepts, and the server must refuse anything that isn't the current offer. This is how QuotWay accepts, simplified:

```ts
async function accept(db: Db, quoteId: string, versionId: string, buyer: Actor, lineIds?: string[]) {
  return db.transaction(async (tx) => {
    const quote = await tx.quotes.get(quoteId);           // status, updatedAt, currentVersionNumber
    const version = await tx.versions.get(versionId);
    if (!version || version.quoteId !== quoteId) throw new Conflict("VERSION_NOT_FOUND");
    if (version.supersededBy != null)                 throw new Conflict("VERSION_SUPERSEDED");
    if (version.versionNumber !== quote.currentVersionNumber)
      throw new Conflict("WRONG_VERSION");                // a stale screen -> 409

    const lines = await tx.versionLines.findMany({ versionId });
    const accepted = lineIds ? lines.filter((l) => lineIds.includes(l.id)) : lines;
    const decisions = Object.fromEntries(lines.map((l) =>
      [l.id, accepted.includes(l) ? "ACCEPT" : "DEFER"])); // not taken = still open, not declined

    const subset = await tx.acceptances.create({
      quoteId, versionId, actor: buyer, decisions,
      hash: sha256(canonicalJson({ versionId, lines: accepted.map((l) =>
        ({ id: l.id, qty: l.quantity, unit: String(l.finalPrice), total: String(l.lineTotal) })) })),
      lines: accepted.map((l) => ({ versionLineId: l.id, quantity: l.quantity,
                                    unitPrice: l.finalPrice, lineTotal: l.lineTotal })),
    });

    // Second guard: the status moves only if nobody changed the quote since it was read,
    // and only along a legal edge (PROPOSAL_SENT -> *_ACCEPTED).
    await tx.quotes.transition(quoteId, {
      expectedUpdatedAt: quote.updatedAt,                 // 0 rows -> conflict
      to: accepted.length === lines.length ? "FULLY_ACCEPTED" : "PARTIALLY_ACCEPTED",
    });
    return subset;
  });
}
```

Two guards, two jobs. The version check makes "accept" mean "accept *this* offer". The conditional status change means that of two buyer actions racing - an accept and a counter from two tabs - exactly one lands; the other gets a conflict and a fresh screen. RFC 9110's own example of a 409 is a versioning conflict, which is the right status for both.

## Unique numbers aren't a single winner
The increment guarantees two concurrent sends get different numbers. It doesn't decide which send should have happened. If two people send from the same state at the same moment, you get versions N and N+1, and the buyer sees N+1 - possibly not the one that was emailed or approved. The fix is to claim the state change in the same transaction as the version, so the second sender fails before writing anything. A recommended shape, not QuotWay code:

```ts
await db.transaction(async (tx) => {
  const claimed = await tx.quotes.updateMany({
    where: { id: quoteId, status: { in: ["SUBMITTED", "IN_REVIEW", "COUNTERED"] }, updatedAt: seenAt },
    data: { status: "SENDING" },                   // or straight to the target status
  });
  if (claimed.count === 0) throw new Conflict("Someone else sent or changed this quote");
  const v = await createVersionInTx(tx, quoteId, input);
  await evaluateApprovalInTx(tx, v);               // the version and its gate commit together
});
```

## A new version supersedes running approvals
An approval belongs to the numbers it approved. In QuotWay, an approval chain is bound to one version, and creating a version - by either side - first marks any chain still running on the quote as superseded, in the same transaction. An approver can't sign off on an offer that has already been replaced. [The approval audit log](/blog/pricing-approval-audit-log#subject) covers what that chain records.

## Catalog changes don't re-price an open quote
The version stores its prices, so a catalog change after sending doesn't touch the offer. It must not touch the order either: when an accepted quote becomes a Shopify draft order, each line has to be sent with the accepted price - for a variant line, an explicit price override - or Shopify prices it at the variant's current catalog price. QuotWay sends the accepted unit price on every line. The checks at conversion then cover what can legitimately move after acceptance - tax, shipping and inventory - and not the negotiated price, which is fixed. [Why a quote converts at the wrong price](/blog/quote-converts-at-wrong-price) has the failure cases, and [the idempotent conversion post](/blog/idempotent-draft-order-creation) covers creating that draft order exactly once.

A new *seller* version is the place to pick up a changed price. In QuotWay, the company-location catalog baseline is re-resolved at each seller send for B2B quotes; the captured catalog price is not.

## Partial acceptance: what the buyer didn't take is still open
An acceptance is its own record bound to one version, with a decision per line - accept, defer or reject - and a copy of each accepted line's quantity and price. Deferred is not declined: it means the buyer hasn't decided. Later, the buyer can accept more of the deferred lines (a new acceptance record; the earlier ones are untouched), or the seller can re-open them as a new version that carries only the still-open lines, never the rejected ones. [Lost or still open](/blog/quote-lost-or-still-open) is the reporting side of the same distinction.

## Showing what changed
With every round a full snapshot, a diff is a pure function of two versions. Diff consecutive versions, match lines by their source-line reference, and report:

- lines added, removed and changed, with price and quantity deltas;
- the total, and shipping;
- discount, terms, deposit and expiry as fields - not only through their effect on the total.

QuotWay shows a diff of each version against the one before in the seller's timeline: lines, quantities, total and shipping. Discount and terms changes show through the total. The buyer sees the current offer and the conversation, not the history. [Editing a quote after the request](/blog/edit-a-quote-after-the-request) is the merchant-side guide to the same rules.

## Why not temporal tables, type 2 dimensions or event sourcing?
| Option | What it gives | Why snapshot rows fit quotes better |
| --- | --- | --- |
| **System-versioned temporal tables** (SQL Server) | Automatic row history on every update | It records every write - "even if no column values change" - not the offers that were sent. PostgreSQL lists system-versioned tables as an unsupported SQL feature, so on Postgres you model versions yourself anyway |
| **Slowly changing dimension type 2** | A new row per change, with effective dates and a current-row flag | The right shape; a version table *is* this pattern with a version number in place of dates |
| **Event sourcing** | Every change as an immutable event; state rebuilt by replay | Heavy for this job - Microsoft advises adopting it only when "its benefits … justify the pattern's complexity" - and replaying a quote that touched Shopify isn't safe |
| **Snapshot rows per version** | One complete, readable record per offer | Readable without replay; the history is the sequence of offers, which is what a dispute asks about |

Snapshot versions sit between the two: append-only where it matters, plain tables everywhere else.

## A checklist for your own build
- A sent offer is never edited; every change is a new, complete version.
- A version holds every line with its prices, quantity and visibility, plus discount, shipping, tax, totals, currency, terms, deposit and expiry.
- A price-hidden line is 0.00 and totals are stored, not summed from displayed rows.
- Version lines reference the working-set line, not through a foreign key.
- A counter is a full version; untouched lines carry forward.
- One editable-state check, used by the editor, line edits, automation and the API.
- Version numbers come from an atomic increment in the same transaction, backed by a unique constraint.
- Every status change is conditional on what was read; the loser gets a conflict, not a silent overwrite.
- An acceptance names its version and is refused unless that version is current.
- A new version supersedes approvals running on the old one.
- The accepted price is forced onto the order; catalog changes can't re-price an accepted quote.
- Deferred lines stay open; rejected lines don't return.
- Diffs are computed between consecutive versions and include discount and terms as fields.
- If sellers may replace a sent offer, it is a supersede, never an edit.

## Where this lives in QuotWay
Every proposal a seller sends and every counter a buyer makes is a numbered, complete version; a sent version's lines and figures are never edited by the app. Once a proposal is out, it doesn't change: the next version comes from the buyer's counter (on Starter and above), from one of your own approvers rejecting it, or from re-opening lines still open after a partial acceptance. An acceptance names its version and is refused if a newer one exists, and accepted lines are recorded with their prices, quantities and a hash of them. The accepted price is sent onto the Shopify draft order, so a catalog change can't re-price it. See [proposals and versions](/docs/negotiation/proposals-and-versions), [counter-offers](/docs/negotiation/counter-offers-and-buyer-prices), [negotiation](/features/negotiation) and [plans](/pricing).

## FAQ

### Can I edit a quote after it has been sent?

Not safely in place. Treat a sent offer as fixed and make every change a new version, so the document the buyer holds, the approval and the acceptance all point at the same numbers. In QuotWay a sent proposal can't be edited; the next version follows the buyer's counter.

### Is a counter-offer a new quote or a new version?

A new version of the same quote: a complete snapshot of every line, with the buyer's changes applied and everything else carried forward from the version they countered.

### Optimistic or pessimistic locking for quotes?

Optimistic. A negotiation round spans minutes to days, and a database lock can't be held across that. Make each write conditional on what was read and reject it when the condition fails.

### Does optimistic locking need a version column?

No. Any condition that proves the row is unchanged works - a version number, an updated-at timestamp, a status, or an HTTP ETag. Check the affected-row count, which must be one.

### What if the buyer accepts an old version?

Refuse it. The acceptance should carry the version id, and the server should reject it with a conflict unless that version is the current, non-superseded one.

### What happens to an approval when the quote changes?

It should be superseded. An approval is bound to the version it approved; a new version needs its own evaluation.

### Does a catalog price change affect an open quote?

Not if the version stores its prices. Send the accepted price explicitly when the quote becomes an order, or the platform will price the line at the current catalog price.

### Should I use temporal tables for quote history?

They record every write rather than every offer, and PostgreSQL doesn't have them built in. A version table - one complete row set per offer - answers the questions a negotiation actually raises.

### How do I show what changed between rounds?

Diff consecutive versions by line: added, removed and changed lines with price and quantity deltas, plus totals, shipping, discount and terms.

## Sources

Read on 15 October 2026:

- Shopify (API 2026-10): [draftOrderUpdate](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/draftOrderUpdate), [DraftOrder](https://shopify.dev/docs/api/admin-graphql/2026-10/objects/DraftOrder), [orderEditBegin](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/orderEditBegin)
- PostgreSQL 18: [transaction isolation](https://www.postgresql.org/docs/current/transaction-iso.html), [explicit locking](https://www.postgresql.org/docs/current/explicit-locking.html), [UPDATE](https://www.postgresql.org/docs/current/sql-update.html), [constraints](https://www.postgresql.org/docs/current/ddl-constraints.html), [unsupported SQL features](https://www.postgresql.org/docs/current/unsupported-features-sql-standard.html)
- Martin Fowler, [Optimistic Offline Lock](https://martinfowler.com/eaaCatalog/optimisticOfflineLock.html) and [Pessimistic Offline Lock](https://martinfowler.com/eaaCatalog/pessimisticOfflineLock.html)
- Microsoft Learn: [EF Core concurrency conflicts](https://learn.microsoft.com/en-us/ef/core/saving/concurrency), [temporal tables](https://learn.microsoft.com/en-us/sql/relational-databases/tables/temporal-tables), [Event Sourcing pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing)
- Kimball Group, [Type 2: add new row](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/type-2/)
- [RFC 9110, HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html) - conditional requests (13.1.1), 409 Conflict (15.5.10), 412 Precondition Failed (15.5.13)

QuotWay behaviour is described from the app's source, read on 15 October 2026; code is simplified, with internal names removed.
