# ADR-139 — A Draft Is Server State, Not Device State (reverses ADR-125's local-only rule)

| | |
|---|---|
| **Status** | Accepted |
| **Date** | 2026-08-14 |
| **Amends** | The draft-persistence rule the `/rentveh/` code attributes to ADR-125. Note: ADR-125's own text never states it — it existed only as `pushPending()`'s "ordinary drafts keep the old local-only behaviour" comment, which is precisely why it was never revisited. ADR-125's actual decisions (tour↔job many-to-many, draft-while-pending, §3 backfill) all stand. |
| **Applies to** | `/rentveh/` today; the same pattern in any offline-first 360LM PWA that keeps a draft state |

## Context

Mukesh reported two problems with the Rented Vehicle PWA on 2026-08-14:

1. Printing a draft produced a sheet with **no images**.
2. Two drafts saved on his phone **did not appear when he signed in on another device**.

Both traced to one decision. ADR-125 made an ordinary draft local-only: it lived in
that device's IndexedDB and was pushed to PostgREST only if it carried a
`pending_request_id` (so a reviewer's approval could backfill it). Everything else
followed from that:

- **Cross-device.** There was nothing on the server to fetch, so the second device
  showed nothing. Working exactly as designed, and useless to the user.
- **Print.** `printSheet()` re-fetched attachments from the server because list views
  omit the image blob for performance. For a local-only draft the server correctly
  answered `200 []`, and the code assigned that straight over the local array —
  discarding the base64 photos it already had in hand.

The reason drafts pile up at all is separate and still open: submitting requires a
job/tour, and creating one from inside this PWA is not at parity with the sales
module. Until that is fixed, "draft" is where real work sits, which is what turned a
design choice into a daily complaint.

Two more defects were found while fixing these, both of which the change would have
made worse (see Consequences).

## Decision

**A draft is server state.** Every pending row is pushed, drafts included. A draft is
private by convention — the team view filters other people's drafts out — but it is
not a device-local secret.

Concretely, in `/rentveh/`:

1. `pushPending()` pushes every `syncState === 'pending'` row regardless of status.
2. `migrate_rentveh_v4.sql` exempts drafts from the two CHECK constraints that assume
   a completed tour (`end_at > start_at`, `end_odo >= start_odo`). A half-filled draft
   has `end_odo = 0` against a real `start_odo`, so **without this every push fails
   with 23514.** The invariants stay enforced for submitted/approved/rejected.
3. `sales.job_pnl` gains `AND re.status <> 'draft'` on its rentveh leg. It sums
   `total_amount` filtered only on `tour_id IS NOT NULL`; that was safe only because
   drafts never reached the server.
4. `printSheet()` **merges** server and local attachments by id instead of replacing.
   An empty server response can never again discard local images.
5. The queued badge and sync banner count drafts, so an unsynced draft is visibly
   unsynced rather than silently pretending to be safe.

## Why (not just what)

The old rule optimised for the wrong thing: it treated a draft as a scratchpad, so
keeping it off the server looked like a saving. But the field treats a draft as
**work in progress that must not be lost** — the tour is still running, the odometer
photo is already taken, and the phone is the only copy. A device-local draft is
therefore the *least* durable place for the most fragile data in the system. Pushing
it costs one upsert and removes a whole class of "where did my entry go".

The print bug is the same mistake in miniature: the code trusted the server to be
authoritative about attachments even for a row the server had never heard of.
**Merge, don't replace, whenever the local copy may be ahead of the server.**

## Consequences

**Fixed as part of this, because syncing drafts would have made each one worse:**

- **Edit-after-pull destroyed data.** A pulled row carries `has_proof` but not
  `proof_image`, and no `atts` at all (`expenses_list_v` omits both by design).
  `openForm()` read `exp.atts || []`, so `lineItems` came up empty and `saveExpense()`
  then rebuilt the row from the form — recomputing `toll_amount` and
  `cc_recovery_amount` to **zero** and writing `proof_image: null` over the server's
  copy. Money and photos gone, no error anywhere. Already reachable for
  submitted/rejected rows; drafts are what people reopen daily, so this would have
  become routine. Now `_hydrateForEdit()` reloads from the server first and the form
  **refuses to open** rather than let a save destroy what it could not load.
- **A fileless line item was never stored.** The attachment push loop opened with
  `if (!a.file) continue;` meaning "already uploaded" — but it also skipped every
  toll or refuel row typed in as a bare amount with no photo. Those rows existed only
  inside the expense's `toll_amount` total and vanished on every other device. Now
  every row is upserted; the image is sent only when the server lacks it.
- **A network-level push failure was silent.** `pushPending()` had no `try/catch`
  around its `fetch`. An HTTP *error response* was handled (DF-05), but a connection
  that drops mid-request *rejects* — and from `saveExpense()` that became an unhandled
  rejection: no `pushError`, no toast, no re-render. Every draft save now goes through
  this path, so a flaky mobile connection would hit it daily.
- **A deleted draft would have resurrected.** `deleteExp()` skipped the server DELETE
  for any draft. The local copy went, the server row stayed, and the next pull brought
  it back.

**Accepted, not solved:**

- **Concurrent edits are last-write-wins.** `RV_UPSERT` is `merge-duplicates` and a
  local pending row beats the server copy on pull. Two devices editing the same draft
  will not merge. The reported need is *visibility*, and one person's two devices are
  not a real concurrency problem — but nothing here detects a conflict.
- **A TM now caches other people's drafts.** The TM pull has no `emp_id` filter, so
  drafts land in TM devices' IndexedDB even though the UI hides them. Same trust
  boundary as every other row in this shared-`web_anon` app; noted, not defended
  against.
- **Drafts now consume server storage**, images included.

## Does NOT govern

- **Job/tour creation parity with the sales module.** The reason drafts accumulate.
  Separate and still open.
- **The other cost streams in `sales.job_pnl`.** `expense.sheets` has the same
  unfiltered-status shape and may be double-counting non-final rows today; `rejected`
  rentveh expenses also still count as job cost. Both are pre-existing, both change
  reported numbers, and both are the business's call — deliberately untouched here.
- **Non-rentveh PWAs.** The pattern is recommended, not retrofitted.
- **Promotion to prod.** `/rentveh/` remains dev-only.

## Revisit If

- **Two people legitimately edit one entry** (a TM correcting a field while the owner
  types) — last-write-wins stops being acceptable and this needs a real conflict
  strategy, probably `updated_at` precondition checks.
- **Draft volume becomes a storage or bandwidth problem** — abandoned drafts with
  photos accumulate with nothing expiring them. A reaper, or an "abandon after N days"
  rule, would be the answer.
- **Drafts ever need to be visible to anyone but their owner** — the current privacy
  story is a UI filter over data every client can read. That is fine for hiding
  half-typed rows from a colleague and not fine for anything stronger.
- **Job/tour parity lands** and drafts stop being where work accumulates — the
  cost/benefit of syncing them is worth re-checking, though the durability argument
  stands on its own.
- **`expenses_list_v` starts carrying attachment metadata** — `_hydrateForEdit()`'s
  extra round trip could then be dropped.

## Verification

`tests/rentveh-drafts.spec.js` — 6/6 passing, two real browser contexts as two devices:

| Test | Guards |
|---|---|
| Mukesh signs in with his real PIN | the reporting user's own account reaches the PWA |
| B1 half-filled draft reaches the server | the push, the relaxed CHECK, and the photo travelling with it |
| B2 draft visible on a second device | the reported cross-device bug |
| A print renders photos held only on the device | the reported print bug, with the push blocked so the images can only be local |
| C delete removes the server row | the resurrection regression |
| D edit-after-pull keeps toll amount and photo | the silent money loss |

`tests/rentveh.spec.js` went 10 passed/5 failed → **11 passed/4 failed**; the four are
a pre-existing cascade from submit-requires-a-tour, unrelated to this change.
