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

**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 sidecar records `kind` (photo / map / document), media type, initial extension, device, and the
domain + path the app was loaded from, plus SHA-256, hold time, status, attempt count, and (opt-in)
coordinates.

**The sidecar outlives the bytes.** Once a peer confirms receipt the blob is deleted and the sidecar
stays, as the holder's receipt and as the record that refuses a second copy of something already
delivered. **The thumbnail stays too**, on the same reasoning carried one step further: after the
bytes move, a grid of grey squares tells the holder nothing, while a grid of small pictures still
says exactly what they had and handed on.

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

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