# ADR-131 ImageBinding Is a Standalone WhatsApp-Paradigm PWA Over a New `media` Schema; the Hub Is a Share Acceptor Only

## Status

Proposed, 2026-08-02.

<!-- When accepted or superseded: update this line and append the same transition below in Status History.
     Do not edit earlier history entries or other sections of an Accepted/Deprecated ADR;
     write a new superseding ADR instead. -->

## Status History

```yaml
status_history:
  - date: 2026-08-02
    status: Proposed
    changed_by: hkl (via DL laptop Claude Code session, Opus 4.8, chunk B0 of the ImageBinding build)
    reason: |
      hkl's vision (2026-08-01, verbatim intent): "our own WhatsApp in the form of a PWA" — a
      dedicated app that reproduces the WhatsApp share motion and chat tracking for 360LM. Photos
      taken in the phone camera are Shared into 360, land in a per-destination folder, carry a
      caption + job/tour/counter context, upload to the VPS so every device sees them, and are
      tracked through a lifecycle (pending -> uploaded -> routed -> consumed) exactly like WhatsApp
      ticks. This ADR freezes the architecture that plan 2.0 (with amendments 2.1 and 2.2) settled:
      a standalone ImageBinding PWA at /imgbind/ owns the whole flow over a NEW `media` schema; the
      hub keeps ONLY the share_target doorway it already has. It records the anchored-post-feed
      interaction model, the two-lens (PWA-folder + job-chat) model over one dataset, the
      anti-chaos chat charter, and the boundary of what "our own WhatsApp" deliberately is NOT.
      D16 (text comments) is resolved IN; D20 (client platform, hkl 2026-08-02) is resolved PWA
      with Flutter analyzed and PARKED. Policy + architecture only; schema DDL, hub SW work, and
      the PWA client land in sibling chunks B1..B11. Proposed pending hkl sign-off, as with
      ADR-125..130.
    changed_via: adr-kit (360lm), authored directly per hkl's ImageBinding plan 2.0 (2026-08-01/02,
      DL session).
```

## Context

hkl's ask, stated verbatim in intent: build "our own WhatsApp in the form of a PWA." Field staff
photograph installations, receipts, odometers, and store fronts on their phones. Today each photo
must be attached inside whichever PWA needs it, one at a time, with no shared album, no
cross-device visibility until upload, no record of who sent what or whether it was ever used. hkl
wants the WhatsApp motion instead: Share -> pick a destination -> caption it -> it lands where it
belongs, is visible on every device, and is tracked to completion.

**This is NOT greenfield — it is extend/complete.** The prior investigation
(`SHARE_TO_360_PLAN.md` §0, verified on the live `lm360` dev DB 2026-08-01) established that the
hub is **already a live Web Share Target**: `hub/manifest.json` carries a `share_target` block
(`action:"/hub/"`, `method:"POST"`, `enctype:"multipart/form-data"`), `hub/sw.js` (`CACHE=
'360lm-hub-v42'`) already intercepts `POST /hub/`, stores the shared blob into IndexedDB
`360lm-hub-v1` / `sharedFiles`, and does `Response.redirect('/hub/?share=1', 303)`; and
`expense/index.html` already reads that same blob into its receipt flow. So the byte handoff works
end-to-end for one PWA. What does not exist: the album, the caption/routing registry, the VPS
upload (cross-device), multi-file intake, and any lifecycle/audit record. Plan 2.0 builds all of
that as a **new, dedicated PWA** rather than growing the hub, because the hub is the most
blast-radius-sensitive surface on the platform (every user's entry point) and must stay a thin
doorway.

**The platform's storage reality this feature rides on** (SHARE_TO_360_PLAN §0.2–0.3): the
platform is already ~70% file-on-disk-behind-a-signed-proxy for real photo corpora (recce, sales,
dispatch, expense via ADR-049); only rentveh is base64-in-DB (ADR-072). `dispatch.proof_photos`
plus its `dispatch_pod_queue` IndexedDB — client compress + signed file-proxy upload returning a
`file_ref` + offline outbox + optional caption — is the closest living sibling and the build
template, not a thing to reinvent.

**Two design questions the research passes resolved** (recorded here because they shape the schema
and are easy to get wrong later):

1. *Chat vs post/thread.* Amendment 2.2 (`IMAGEBINDING_RESEARCH_NOTE.md`,
   `IMAGEBINDING_CONCEPT_COMPARISON.md`, and a Slack-concept addendum) ran a fair 3-then-5-way
   comparison (chat, forum, hybrid post-feed, Slack channels+threads, Zulip topics). The **hybrid
   anchored post-feed** was the only column that failed none of the four charter rows (closure,
   ERP anchoring, history-at-scale, two-lens); chat and Slack both fail the same three. The
   one-liner: *we are building Basecamp's Message Board, not its Campfire.*
2. *The decisive internal argument for item-anchored comments.* Amendment 2.1 promised "one
   `media.items` row, two lenses, zero duplication." But a text comment scoped only to a thread
   (a `target_pwa` OR a `job_id`) with no link to the image would be **invisible in the other
   lens** — silently breaking the charter the moment anyone discusses a photo. Anchoring comments
   to the item (`messages.item_id`) fixes this by construction: the comment travels with the image
   into both lenses, exactly as `media.events` (already item-keyed) does.

**Client platform (D20, hkl 2026-08-02).** hkl asked whether ImageBinding should be a Flutter app
instead of a PWA. Analyzed in `IMAGEBINDING_PLATFORM_COMPARISON.md`: Flutter wins the product's
"soul" (native iOS share sheet + first-class push — the two rows a PWA is capped on forever), but
its iOS advantage is gated on the still-open distribution tail (Q6 keystore, Q8 Codemagic/Apple).
The PWA ships to **every** device today (iOS degraded to the in-app picker floor, no push). hkl
chose **PWA**; Flutter is PARKED, not rejected. This is not a one-way door: the `media` schema, the
hub intake, and the destination-PWA trays are identical either way, so a Flutter client can replace
or coexist with the PWA later at zero server rework.

## Decision

**Decision Maker:** hkl

### 1. ImageBinding is a standalone PWA at `/imgbind/`; the hub is a share acceptor ONLY

A new PWA — **ImageBinding**, pre-default directory `/imgbind/`, hub tile "🖼 ImageBinding"
(hkl-overridable) — owns the entire WhatsApp-paradigm media flow: the folder list, the threads,
the lifecycle ticks, the comments, the local IndexedDB album, and the background sync. The **hub's
role is frozen at doorway only**: it keeps the `share_target` on its manifest, its SW catches the
POST, stores the blob(s) into IndexedDB, and redirects to **`/imgbind/?share=1`** (replacing the
current `/hub/?share=1` self-redirect). The hub gains no album, no `media` reads/writes, no
routing UI. ImageBinding is the WhatsApp app; the hub is the OS share sheet entry.

### 2. A new `media` schema of FIVE tables backs it

The feature is backed by a new Postgres schema, `media`, owned by ImageBinding. **Five tables**
(roles below; the DDL is chunk B1, not this ADR):

- **`media.items`** — one row per shared image (a pointer, never bytes): opaque `id`, `batch_id`
  (groups a multi-photo share into one album-post), `file_ref`/`thumb_ref` (disk pointers behind
  the proxy), `caption`, `uploaded_by`, `uploaded_at`, `target_pwa` (destination folder, NULL until
  routed), `route_id`, `context_refs` (jsonb: job/tour/counter handles, name-first per ADR-130),
  `status` CHECK IN (`pending`,`uploaded`,`routed`,`consumed`), `device_origin`.
- **`media.routes`** — the destination-PWA registry (v1 design verbatim). The **folder list is the
  DISTINCT set of `target_pwa` routes** (RentVeh, Recce, Expense, Tour Expense, Sales-Challan,
  Installation, Production…), each with its icon, accepted context, and required attributes.
- **`media.messages`** — human text. A **tri-state key**: if `item_id` (FK -> `media.items`) is
  set, the row is a **comment on a post** and renders in BOTH lenses; if `item_id` is NULL it is a
  **thread-level note** with **exactly one** of `target_pwa` or `job_id` set. Plus `author`, `body`,
  `at`.
- **`media.events`** — the append-only lifecycle/audit log: `item_id` FK, `kind` CHECK IN
  (`shared`,`uploaded`,`routed`,`consumed`,`returned`,`deleted`), `actor`, `at`, `meta` jsonb.
  This is both the tick engine and the "who sent what where, who used it, when" audit trail.
- **`media.chats`** — one row per JOB chat: `job_id` **UNIQUE NOT NULL** REFERENCES `sales.jobs`,
  `created_by`, `created_at`, `status` CHECK IN (`active`,`archived`). Enforces at most one chat
  per job. Items need no new column for job-chat membership — it is DERIVED from `context_refs`.

### 3. Interaction model — an anchored POST-FEED, refreshed by polling (no websockets)

Each share is a **post** (single image or a `batch_id` album, + caption + context chips + per-post
lifecycle ticks). Comments **nest under the post** and are written with `messages.item_id` set, so
they render in every lens the post appears in. Nesting is **one indent maximum, flat-with-reply-to**
(a reply re-parents to the post; imageboard/Facebook rule) — a field phone thread must never become
a staircase. The feed adopts the **TinyIB imageboard idiom by name** (MIT; the 15-year-proven,
framework-free, image-first precedent): OP-post = image+caption, flat replies, client polls for new
replies. Refresh is **visibility-gated cursor-delta polling** on the open thread (`at=gte.<cursor>
&order=at`, merge/dedupe by id using `>=`, ~10–15s while visible, paused when hidden, refreshed on
focus/online); folder badge counts come from `Prefer: count=exact` + `limit=0` reading
`Content-Range` (~30–60s). **No websockets, no server push in the ship line.**

Item-anchored discussion is not just our preference — it is where systems converge at scale:
**Slack removed standalone file-comments in 2018 and folded them into threads on the file**, i.e.
discussion belongs attached to the object, not to a free-floating channel message. That convergence
is the external evidence for `messages.item_id`.

### 4. Two lenses over ONE dataset (zero duplication)

The home screen has two tabs, both reading the SAME `media.items` rows:

- **PWAs tab** — the *delivery* lens: the folder list (DISTINCT `target_pwa`), where images go to be
  consumed.
- **Jobs tab** — the *project* lens: one chat per job (`media.chats`), showing every image whose
  `context_refs` touch that job (from any sender, routed to any PWA) plus that job's messages,
  merged.

An image shared to "RentVeh / Suraj's expense on the KFC job" appears in the RentVeh folder AND the
KFC job chat — **the same row, no forwarding, no copy**. This is the "complete system" payoff
WhatsApp cannot give: every image and message is queryable by job for later recce PPTX pulls, P&L
context, and client reporting.

### 5. Anti-chaos charter — chats are gated, derived, and auto-named (enforced by schema + gates)

Job chats must never devolve into WhatsApp-group sprawl. Hard rules, enforced structurally, not by
etiquette:

- **Chats anchor to real entities only.** v2.1 = **jobs only**, `UNIQUE(job_id)` — at most one chat
  per job. Tour-chats etc. are future amendments, never user-creatable free-form options.
- **No free-form groups, no ad-hoc member invites.** Membership is **DERIVED and recomputed live**:
  employees assigned to any tour linked to the job (via `sales.tour_jobs` -> assigned employees) +
  anyone holding `sales`/`tour_approve` + the TM. It stays correct as tours change.
- **Creation is gated:** only `sales`/`tour_approve` holders create a job chat (pre-default,
  hkl-overridable -> decision D18). Others see it once it exists.
- **Chat NAME = the job's `job_name`, automatically** — never user-typed; the chat has no identity
  of its own, the job is the identity (ADR-130 discipline).
- **No DMs, no cross-job forwarding, no message editing.**

The empirical anti-example is **Slack's channel-sprawl pathology**: unconstrained, freely-created,
freely-named channels multiply until discovery and membership decay. The gated / derived /
auto-named charter is the deliberate inverse of that failure mode.

### 6. Boundary — what "our own WhatsApp" is deliberately NOT (v2.0)

To keep scope honest and prevent smuggling a full messenger in: **no** person-to-person DMs (threads
exist per PWA-folder and per job only); **no** presence / typing / per-person read-receipts; **no**
end-to-end-encryption claims; **no** voice or video; **no** message editing (delete-for-all is an
admin action only). Text comments are per-thread/per-post, plain, and attributable. If person-to-
person chat is ever wanted, that is a different product — flagged here, not smuggled in.

### 7. Client platform = PWA (D20); Flutter analyzed and PARKED

The ImageBinding client is a **PWA**, built to the house single-file idiom (ADR-013). A Flutter
client was analyzed (`IMAGEBINDING_PLATFORM_COMPARISON.md`) and **parked**, not rejected:
availability wins — the PWA reaches every device day-one (iOS via the in-app picker floor). The
backend (`media` schema, hub intake, destination trays) is **platform-agnostic**, so a Flutter
client swap later is cheap and this is not a one-way door. Availability is two-bucket:
**Android-installed hub = OS share sheet** (the premium path), **iOS / desktop = in-app picker**
(the floor).

## Implementation Notes

This ADR is **architecture + policy, not migration** — it creates no schema and no client code. It
is the authority the sibling chunks execute against:

- **`media` schema DDL — FIVE tables** (`items`, `routes`, `messages`, `events`, `chats`), grants,
  `PGRST_DB_SCHEMAS += media`, PostgREST schema-cache reload, and the `/imgbind/` registry row land
  in **chunk B1**. NOTE: plan 2.0's §5 B1 line says "4 tables" — that count is **stale**, written
  before amendment 2.1 added `media.chats`; B1 must build **five**.
- **Files live on disk behind the generalized ADR-049 HMAC-signed proxy** (`hub-media/` pattern),
  DB holds pointers only; bytea is rejected as it is platform-wide. Grants ride the shared
  `web_anon`/`authenticator` role model per **ADR-128** (no per-PWA Postgres roles). These are
  chunk B1/B2 work; noted here as consequences.
- **Hub SW / manifest change** (multi-file `share_target`, blob store, redirect to `/imgbind/
  ?share=1`) is **chunk B2** = SHARE_TO_360 M2 verbatim. Touching the hub SW is the single most
  blast-radius-sensitive edit in the plan; land it only with hkl's go and never mid-field-test-week.
- **Client (PWA):** shell + folder list + IndexedDB `imgbind-v1` + outbox = B3; thread post-feed =
  B4; send flows = B5; destination-PWA trays = B6; comments + badges = B7; job-chat create/derive/
  archive = B7b; deep pre-attach (Recce, RentVeh-odo) = B8a/B8b. Ship line = **B0 through B7b**.
- **Compression** reuses the recce longest-side `compress()` shape (returns a Blob); do not add a
  fifth `compressToCanvas` variant (SHARE_TO_360 §0.4).
- **Batch rule (gotcha):** a multi-photo share is ONE post keyed by `items.batch_id`. Never group a
  post by uploader+timestamp — route-later can diverge a batch's items across time.
- Dev (`lm360`) only. No prod cutover implied or suggested.

- **Files:** new `/var/www/360lm/imgbind/` (B3+); `hub/manifest.json` + `hub/sw.js` (B2, redirect
  target only); destination PWA tray snippets (B6/B8).
- **Key identifiers:** schema `media`; tables `media.{items,routes,messages,events,chats}`;
  `PGRST_DB_SCHEMAS`; IndexedDB `imgbind-v1`; hub IndexedDB `360lm-hub-v1`/`sharedFiles`.
- **Find all sites:** `grep -rn "share_target\|sharedFiles\|/hub/?share=1" /var/www/360lm --include="*.js" --include="*.json" --include="*.html"`

## Alternatives Considered

- **Grow the hub instead of a new PWA.** Rejected. The hub is every user's entry point and the most
  blast-radius-sensitive surface; an album + routing registry + VPS upload + threads on it would
  put a heavy, evolving feature on the one PWA that must stay a thin, stable doorway.
- **Chat-as-tracker (a single scrolling message stream, WhatsApp Campfire-style).** Rejected in a
  fair multi-concept comparison: chat fails closure (no "done"), ERP anchoring (messages float in a
  channel, not welded to an evidence item), history-at-scale, and the two-lens promise. It won only
  the familiarity / least-JS / offline-simplicity rows — none charter-critical. "Message Board, not
  Campfire."
- **A forum/threaded-board.** Rejected narrowly: tied the hybrid on every substance row but lost on
  capture ceremony (a forum makes posting a photo heavier than a share should be).
- **Slack-style channels + threads-on-any-message.** Rejected: lands as "chat-plus" — better
  retrieval than chat, but threads anchor to a message in ONE channel, so it still fails closure,
  ERP anchoring, and the two-lens model. Its own history (2018 file-comments -> threads) and its
  channel-sprawl pathology are cited above as evidence FOR the chosen design, not for adopting Slack.
- **Per-lens duplication / forwarding** (copy an item into the job chat AND the PWA folder).
  Rejected: duplicates rows, desynchronizes captions/status, and breaks single-source audit. The
  two-lens-over-one-dataset design (derived membership via `context_refs`) gives both views with one
  row.
- **Thread-scoped comments without an item link** (message carries only `target_pwa` or `job_id`).
  Rejected — the decisive internal argument in Context: such a comment is invisible in the other
  lens and silently breaks the zero-duplication charter. `messages.item_id` fixes it by
  construction.
- **Flutter client now.** Analyzed and **parked** (D20), not rejected. Native iOS share sheet +
  push are real wins, but gated on the open Q6/Q8 distribution tail; the PWA reaches every device
  today. Backend is platform-agnostic, so revisiting Flutter later is cheap.
- **Websockets / server push for freshness.** Rejected for the ship line: visibility-gated polling
  needs zero server changes and is adequate for a field-photo cadence; push is deferred to the
  hkl-gated chunk N1 (decision D17).

## Consequences

**Positive:**
- One WhatsApp-shaped surface for all field media, with cross-device visibility, a per-destination
  album, and lifecycle ticks — replacing per-PWA one-at-a-time attach with no shared record.
- Every image and message is queryable by job (two-lens, zero duplication), which directly feeds
  later recce PPTX generation, per-job P&L context (ADR-129), and client reporting.
- `media.events` is a permanent, append-only, actor-stamped audit trail — who sent what where, who
  consumed it, when — for free, as a side effect of the tick engine.
- The hub stays a thin, stable doorway; the new feature carries its own schema and its own PWA, so
  its blast radius is contained.
- Platform choice is reversible: a Flutter client can replace or join the PWA later with zero server
  rework (D20 is not a one-way door).

**Negative / Trade-offs:**
- A new schema, a new PWA, a hub SW/manifest bump, and destination-PWA tray edits — real surface
  area, and the hub edit (B2) touches every user's entry point.
- Polling-only freshness: no instant delivery and no notification pings until the hkl-gated push
  chunk (N1 / D17); folder badges are the only nudge in the ship line.
- iOS is the degraded bucket — no OS share sheet (WebKit has no `share_target`), so iOS users use
  the in-app picker floor. This is a permanent PWA cap, the reason Flutter stays on the table.
- Derived chat membership recomputed live is correct-by-construction but adds query cost and depends
  on `sales.tour_jobs` / role data staying accurate.

**Risks and mitigations:**

| Risk | Mitigation |
|---|---|
| B1 builds only 4 tables (following plan §5's stale "4 tables" line) and silently drops `media.chats` | This ADR §2 and Implementation Notes state FIVE tables explicitly; the findings log flags the stale line; B0 output calls it out to the B1 executor |
| Hub SW/manifest bump collides with a live field trial | Land B2 only with hkl's explicit go, never mid-field-test-week (standing SHARE_TO_360 governance rule) |
| A comment written in one lens is invisible in the other, breaking the charter | `media.messages.item_id` anchors comments to the item so they render in every lens the post appears in — enforced by the schema, not by UI discipline |
| Job chats proliferate into WhatsApp-group sprawl | `UNIQUE(job_id)`, gated creation, derived membership, and auto-name (§5) make sprawl structurally impossible; Slack's channel-sprawl is the cited anti-example |
| Multi-photo posts fragment or mis-group | `items.batch_id` is the durable album key; never group by uploader+timestamp (route-later diverges a batch) |
| Scope creep into a full messenger (DMs, presence, voice) | §6 boundary is explicit and enforced by the absence of the supporting tables; person-to-person chat is flagged as a separate product |
| iOS field staff get a degraded experience | In-app picker floor works today on every iOS device; Flutter (D20 parked) is the revival path if the OS share sheet becomes a hard requirement |

## Related Decisions

- **ADR-049** — Recce View Files via HMAC-Signed Proxy. `media` file bytes live on disk and are
  served only through the generalized ADR-049 proxy; DB holds pointers, bytea rejected.
- **ADR-072** — Proof Image Capture & Annotation Standard. The client compress/quality rules
  ImageBinding capture follows; `dispatch.proof_photos` (ADR-072 + ADR-049) is the build template.
- **ADR-128** — Shared PostgREST role model. `media` grants ride `web_anon`/`authenticator`; there
  are no per-PWA Postgres roles.
- **ADR-130** — Opaque Immutable IDs + Renameable Display Names. `media.chats` has no identity of
  its own — name = the job's `job_name`; `context_refs` and all pickers follow the `Name (ID)`
  convention. This ADR mirrors ADR-130's section layout as the house format.
- **ADR-013** — Single-file PWA idiom / no React. The ImageBinding PWA (D20) is built to it; the
  parked Flutter client would be exempt under its own FL-ADR.
- **ADR-020 / ADR-088** — Offline-first IndexedDB primary + schema versioning. The `imgbind-v1`
  IndexedDB (blobs, items mirror, messages, outbox) follows both.
- **ADR-105** — Hub-issued signed JWT for proxy/native auth. The file proxy and any future Flutter
  client authenticate through it.
- **ADR-030** — Searchable type-ahead pickers (no `<select>` > 5 options). The context (job/tour/
  counter) pickers ride on it.
- **ADR-084** — List pagination / `Content-Range` counts. Folder badge counts use its
  `Prefer: count=exact` + `Content-Range` pattern.
- **ADR-087 / ADR-023** — Push notification capability. The deferred N1 push chunk (D17) builds on
  these; the ship line ships in-app badges only.

## References

- **ImageBinding plan 2.0** (self-sufficient, hkl-owned): `C:\Users\Lenovo\Documents\
  tour-job-architecture\IMAGEBINDING_PLAN_2.0.md` — §1 analogy map, §2 architecture, §3 boundary,
  §3.5 amendment 2.1 (sender identity, two lenses, anti-chaos charter, `media.chats`), §3.6
  amendment 2.2 / 2.2-b / 2.2-c (anchored post-feed, `messages.item_id`, `items.batch_id`, polling
  refresh, TinyIB idiom, the two binding Slack citations), §4 data model (FIVE tables), §5 chunk
  table (note the stale "4 tables" on the B1 row).
- **SHARE_TO_360_PLAN.md** (v1, inherited investigation): same folder — §0 verified platform/storage
  facts (hub is already a live share target: `hub/manifest.json` share_target, `hub/sw.js`
  `CACHE='360lm-hub-v42'` POST handler + IndexedDB `sharedFiles/'pending'`, `expense/index.html`
  reader ~L1901/L3907), §0.2 base64 vs file-proxy patterns, §0.3 attachment-point audit
  (`dispatch.proof_photos` + `dispatch_pod_queue` = build template), §0.4 compressToCanvas
  inventory.
- **`IMAGEBINDING_PLATFORM_COMPARISON.md`** (same folder) — D20 analysis; PWA chosen, Flutter parked;
  "not a one-way door"; two-bucket availability (Android OS share sheet / iOS in-app picker floor).
- **`IMAGEBINDING_RESEARCH_NOTE.md`** and **`IMAGEBINDING_CONCEPT_COMPARISON.md`** (same folder) — the
  research that settled the anchored post-feed; the "Basecamp Message Board, not Campfire" verdict;
  the five-concept comparison in which the hybrid is the only zero-fails column.
- **Slack, 2018** — removal of standalone file comments, folded into message threads on the file:
  the at-scale convergence evidence for item-anchored discussion (this ADR §3).
- **Slack channel sprawl** — the widely-documented pathology of unconstrained channel creation: the
  empirical anti-example for the gated/derived/auto-named chat charter (this ADR §5).
- **TinyIB** (MIT) — the framework-free, image-first imageboard idiom the post-feed adopts by name
  (OP-post + flat replies + poll for new).
- Open hkl decisions: **D16** (text comments) resolved IN; **D17** (push now vs badges) — pre-default
  badges only; **D18** (who may create a job chat) — pre-default sales/tour_approve; **D19** (`'ack'`
  reaction as a widened `media.events.kind`) — pre-default adopt, hkl-gated; **D20** (client platform)
  resolved PWA (hkl 2026-08-02). Tracked in `DECISION_SHEET.md`.
