# साक्षी · Observer — v0.6.3

**You are a supply of imagery.**

A firefighter's phone is a **pole on the commons**, not a source directory. It holds photos, maps and
documents with no network, and it **declares** what it holds. What propagates is **metadata**. The
bytes stay on the phone until a peer binds to the declaration and asks for something specific.

Live: <https://guthi.live/observe/> — upstream deploy recorded in the header comment of `app.js`.

This is the fourth door on guthi.live — the first one that takes something from a
person rather than only showing them the record. The app is unchanged in substance;
it was renamed for the people who will use it and its location handling was fixed
(see "Where a photograph says it was" below).

## This is not an upload

Stephen, 2026-07-31, correcting the model this app was first built on:

> *"it is not a dropbox 'upload' / it will popagate needs;/ for backup / and will propagae
> capabilities/ i am a supply of imagery / it will be meta data / language is not upload."*

**"Upload" is not avoided for taste. It names the wrong physics.** Upload says: the stock leaves,
the node empties, a server owns it. **Declare** says: the pole publishes, the stock stays, a peer
may bind.

The argument, distilled from [sasha-ii `019fb303`](https://redfish.acequia.io/guerin/.agents/019fb303-17c9-7a92-8298-ad73d6043627/):

1. An antisymmetric correlator is **exactly zero at equal time** — a body reporting on itself,
   alone, reads nothing.
2. So value lives on the **link**, not the node. The commons **governs flows, not stocks.**
3. Therefore the correct act is not to move the stock but to **declare the flow**.

## The two poles

The phone carries both, which is what makes it bindable at all. A **+** alone is a brochure; a **−**
alone is a complaint.

| Pole | Published at | Says |
|---|---|---|
| **+** | `<link>/capabilities/<installId>.json` | *"I am a supply of imagery."* — inventory metadata: kinds, media types, counts, bytes, time span, place, hashes, and a per-item manifest |
| **−** | `<link>/needs/<installId>.json` | *"What I hold needs backup."* — the lack, stated in terms a peer can act on |

A peer that wants something PUTs a demand at `<link>/demands/<installId>.json`:

```json
{ "want": ["<item-id>", "<item-id>"] }      // or  { "want": "all" }
```

Only what was asked for moves. **Everything else stays on the phone.** A `404` on the demand
document is the ordinary answer, not an error — an unbound pole is a normal state.

## Language (binding)

From [john's narrative](https://redfish.acequia.io/guerin/.agents/6715f286-ef8e-4e95-a94d-1b83ee5aa043/2026-07-31/notes/)
(`ethos=9, pathos=2, eros=6, logos=9`). **Enforced by an automated gate in the test suite** — the
banned words fail the build if they reach the UI.

| Do not say | Say |
|---|---|
| Upload · Send · Submit | **Declare · Offer · Publish** |
| Collector · Server · Endpoint | **Peer · Link · The commons** |
| Sent | **Moved** |
| Pending · Failed to send | **Held · Unbound** |

The interface's entire pathos budget is one line, spent once: *"What you hold stays yours until
someone asks for it."*

## Getting a GeoPDF out of Downloads

Three entry points with genuinely different reach. Verified 2026-07-31.

| Rung | Entry point | Needs install? | Where it works |
|---|---|---|---|
| **0** | **Add maps & documents** button | **No** | **Everywhere, iOS + Android** |
| 1 | OS share sheet (Web Share Target) | Yes | Android / Chromium only |
| 2 | "Open with" from a file manager (File Handling API) | Yes | **Desktop Chromium only** |

**Rung 0 is the one that works on a phone.** Android's document picker (SAF) already reaches
Downloads, Drive and the SD card; `accept="image/*"` was hiding them. The accept list carries **both
MIME types and extensions** because Android filters on MIME and iOS on extension.

Accepted: `.pdf` (GeoPDF), `.kml`, `.kmz`, `.geojson`, `.gpx`, `.tif`/`.tiff`, `.zip`, plus images.

### Non-photos are held byte-for-byte

Only **photos** are ever downscaled. A GeoPDF, KMZ or GeoTIFF is held and delivered **verbatim** —
re-encoding one throws away exactly the bytes its georeference lives in. Asserted by hashing the
original and the stored blob and requiring a match.

## The three tabs

**Live** (landing) captures; **Gallery** holds and inspects; **Map** places. Deep-linkable as
`#live`, `#gallery`, `#map`.

## The gallery (v0.5.0)

A pooled grid with a properties panel, on Stephen's ask of 2026-08-06.

- **Div pools.** The grid never grows a node per item: a fixed pool covers the viewport plus two
  rows of overscan **above and below**, and cells are re-bound as you scroll either way. 48 items
  paint 24 divs; 800 items paint the same 24. The scrollbar still reflects every item, because the
  sizer's height is computed from the real count.
- **Thumbnails are minted once and cached as sidecars.** First sight of an item decodes it at full
  resolution, writes `<guid>.thumb.jpg` (~2 KB, 320px long edge) beside the bytes, and every later
  paint reads that file. Maps and documents have no picture in them and show their kind instead.
- **Properties on the left**, like Google Photos: on a laptop a 300px column beside the grid; on a
  phone a full-panel sheet over it with its own scroll, because the page itself must never scroll.
  The rows are **deliberately dense** — this is the one surface in the app that is read as data
  rather than pressed with a glove. Every control in it still holds the 44px touch floor.
- **Lat, long and the rest of the telemetry.** Position to six decimals with accuracy, and for a
  Live capture the whole track from its telemetry sidecar: fixes, altitude, heading, speed, span,
  compass and device attitude, and what the camera was actually doing (frame size, rate, facing,
  zoom). **On the map** carries the coordinate to the Map tab.

## How it holds things

Up to four files per item in the origin private file system, under `gallery/`:

```
gallery/<guid>.<ext>            the bytes
gallery/<guid>.json             the meta sidecar
gallery/<guid>.telemetry.json   where the phone was and how it was held (Live captures)
gallery/<guid>.thumb.jpg        the cached thumbnail (minted on first sight)
```

**The bytes are the file the contributor picked, byte for byte (v0.6.0).** Nothing is downscaled or
re-encoded, whatever the kind: a photograph keeps its EXIF and any HDR gain map, a GeoPDF keeps its
georeference, a HEIC stays a HEIC. The only change is the name — a GUID, so two phones can never
collide — and the original filename is kept in the sidecar as `originalName`. The thumbnail is the
one derived image anywhere in the app, and it is a sidecar, never a substitute.

**HEIC / HEIF (v0.6.0).** Chrome on Android and desktop cannot decode HEIC, which is what an iPhone
hands to an Android phone. The held bytes stay HEIC; for the thumbnail only, the app decodes through
libheif compiled to WebAssembly (`vendor/libheif/`, libheif-js 1.19.8, LGPL-3.0). The 1.46 MB decoder
is loaded lazily on the first HEIC seen and kept in the shell cache, so a phone that only ever sees
JPEGs never pays for it and a phone with no signal still has it. Safari decodes HEIC natively and
never touches it. The photograph's EXIF GPS is read from the HEIC by exifr just as from a JPEG.

The sidecar records `kind` (photo / map / document), media type, initial extension, original filename
and size, device (user agent, platform, screen, install id), and the domain + path the app was loaded
from, plus SHA-256, hold time, status, attempt count, and coordinates with their `source`.

**The whole record goes on the wire (v0.6.3).** The sidecar written beside the bytes on the server and
the item lists in the two poles are the same record the phone holds — nothing is stripped. The server
copy is a backup of the hold, and a photograph that will later be calibrated and georectified needs
the particular phone identified: `device` is what lets a downstream step look up that model's lens
intrinsics and sensor extrinsics. (Up to v0.6.2 an allowlist dropped `originalName`, `originalBytes`
and most of `device` on the way out.)

**The bytes stay on the phone (v0.6.1).** A peer binding to the declaration takes a *copy*; the pole is
not emptied. Up to v0.6.0 the blob was deleted the moment the far end confirmed, which turned
"declare" back into "upload" by the back door. Now the original stays in OPFS beside its sidecar and
thumbnail; `status: sent` and `movedAt` are the receipt, and the sidecar is still the record that
refuses a second copy of something already delivered. Freeing space is the holder's decision, made
in the gallery, never a side effect of someone else asking.

## Where a photograph says it was

`coords` is **the photograph's own claim about where it was taken**, read from its EXIF, with
`source: 'exif'`. Only when the picture does not say does the app fall back to the device fix
(`source: 'device'`) or to nothing (`source: 'none'`) — and in both of those cases the item is
marked `placed: false` and shown to the contributor as *needing placing*.

**This is a change from v0.5.0, and a deliberate one.** `coords` used to mean *where the phone
was standing when the item was taken in hand*, resolved **once per batch** and stamped onto every
file in it. For a crew photographing a fire in front of them that is nearly right. For a flood,
where what people have is a gallery full of pictures taken hours ago and miles apart — and
pictures that were sent to them — it is wrong in the specific way that matters: ten photographs
from ten places all claimed one location, and the claim looked exactly as authoritative as a real
one.

The old principle still holds and is now enforced rather than assumed: **a device fix and a
photograph's own georeference are different claims and are not conflated.** They are simply both
representable now, and which one you have is a field you can read, not something to infer from
context. A GeoPDF still carries its own georeference in its own bytes and is still never
re-encoded.

EXIF is read from the **original File, before any downscale** — a canvas re-encode carries no
EXIF, so reading afterwards would find nothing on exactly the large photographs that get
re-encoded.

## Platform notes (verified 2026-07-31, not assumed)

- **All OPFS writes go through a dedicated Web Worker using `createSyncAccessHandle()`.** Safari does
  not implement `createWritable()`; the sync access handle is worker-only by spec but *is*
  implemented in Safari, Chrome and Firefox, so this is the one write path that works everywhere.
- **Background Sync is Chrome + Samsung Internet only.** On iOS the declaration republishes on page
  load, on `online`, and on `visibilitychange` → visible.
- **Web Share Target is Chromium/Android only.** The service worker collects files from *every* form
  field, so a share matched against the second declared file param cannot be silently dropped.
- **The File Handling API is desktop-only** and requires installation. It does not help on a phone.
- **Storage eviction**: WebKit evicts on an origin LRU basis and exempts origins in *persistent*
  mode, granting persistence on heuristics that include running as a Home Screen web app. On iPhone,
  **Add to Home Screen is a genuine durability upgrade.**
- Secure context is required and enforced — the app refuses to run on plain HTTP rather than failing
  silently on a real device.

## Verification

Earlier releases: 16/16 (v0.3.0) and 24/24 (v0.4.0) against real Chrome over CDP — items held
offline, survival across force-quit with zero signal, SW-served cold open, GeoPDF/KML/GeoTIFF held
byte-identical, both poles published as metadata with **zero bytes moved while unbound**, a demand
for 3 moving exactly 3, a demand for `all` completing despite forced failures with no duplicates.

**v0.5.0: 39/39 local, run against a real Chrome with a fake camera and a geolocation override**
([`verify.mjs`](https://redfish.acequia.io/guerin/.agents/019fb854-a9de-7554-8fd3-e78ea5fb94cb/2026-08-06/artifacts/verify/verify.mjs)
in the bead) — the pool stays 24 divs for 48 items scrolling both ways, thumbnails land as sidecars
and are read back rather than re-minted, a Live capture writes a real telemetry document, the panel
opens to the left of the grid showing six-decimal lat/long and the camera's actual 3840×2160 track,
rows average 22.6px, plus the standing gates: lexicon, no-alarm, and one page with no scroll across
**three tabs × three phone sizes**, the properties sheet included.

The one console error is the CORS preflight against `share.realtime.earth`, which is the F1 blocker
below and not a defect: an unbound pole is a normal state. It is counted separately so that the day
a peer answers, the count goes to zero.

**Not yet field-tested on a real iPhone or Android in airplane mode.** Required before this is
trusted on a fire.

## Changes

- **v0.6.3 — 2026-09-02.** The full sidecar travels: server sidecar and pole item lists carry the whole
  record (`originalName`, `originalBytes`, full `device`, plus `chain.tokenId`), not the old allowlist.
  `sw.js` cache `realobserver-v0.7.3`.
- **v0.6.2 — 2026-09-02** (another agent, deployed directly to guthi.live; synced into the repo as its
  own commit). Per-contributor keys: on first declare the phone mints a child token scoped to
  `gallery/<installId>/` and every byte after that rides the child; the byte PUT is proven with a HEAD
  before a 409 is believed; a refused key is discarded and re-minted. `sw.js` cache `realobserver-v0.7.2`.
- **v0.6.1 — 2026-09-02.** The bytes stay local after a peer takes a copy (Stephen: "it should still
  be local though"). `fulfill()` no longer deletes the blob; `blobRemoved` stays false; the OS share
  sheet offers everything still held, sent or not. `sw.js` cache `realobserver-v0.7.1`.
- **v0.6.0 — 2026-09-01.** Originals held byte for byte (no downscale, no re-encode), renamed to a
  GUID with `originalName` in the sidecar. HEIC/HEIF thumbnails via a vendored wasm libheif, loaded
  lazily. Files that arrive with no MIME type (Android often hands over a HEIC that way) are
  classified by extension, so they are thumbnailed and read for GPS instead of falling through to
  *document*. `sw.js` shell cache bumped to `realobserver-v0.7.0`.
- **2026-09-01 — repo synced from live.** The deployed site had moved past the repo (Nepali title,
  i18n, vendored exifr, per-photo EXIF location). Copied back into the bead's `repo/realobserver/`
  as commit `692cd28`; from here on the repo is the source and guthi.live is the deploy.
- **v0.5.0 — 2026-08-06.** The gallery: pooled grid, thumbnail sidecars, properties panel on the
  left. Then (live only) `coords` became the photograph's own EXIF claim, with the device fix as an
  honest, labelled fallback.
- **v0.4.0 — 2026-07-31.** Live first, three tabs, share intent, My Instructions.
- **v0.3.x.** The phone is a pole, not an uploader; one page on mobile.
- **v0.2.0.** Other locations: reach Downloads for the fire GeoPDF.
- **v0.1.1.** Offline-first photo queue for wildland crews.

Source of truth: bead [`019fb854`](https://redfish.acequia.io/guerin/.agents/019fb854-a9de-7554-8fd3-e78ea5fb94cb/)
`repo/realobserver/` (branch `master`). Deployed to <https://guthi.live/observe/> by WebDAV
server-to-server COPY; no `.git/` travels.

## Settings

`?link=<url>` (or the in-app Settings field) changes the link this phone declares on. `?endpoint=`
still works for older links. What you hold is never lost by changing it.
