# Story-map token-propagation design (note 02)

Chelan Hills story-map page, contribution model, designed before build. Bead `68d88c38`,
2026-07-23. Companion notes: `00-rls-digest.md` (what the spine may say), `01-incident-record.md`
(the verified record). Stephen's program, verbatim anchor: "we are in some ways recreating the
user dashboard for the domain but withing a directory without users. just token propagation."

Standing gates honored throughout: the story spine is the factual record only (RLS digest plus
verified incident record, witness grammar, RLS credited as the authoritative account, no names
of the entrapped beyond what the RLS publishes); the page embeds exactly one public-by-design
scoped key; the invite log is always public; v1 chains are provenance, with cryptographic
delegation named honestly as future work.

---

## 1. Thesis: a user dashboard with no users, inside one directory

A domain dashboard normally gives you accounts, login, per-user permissions, an audit trail,
and a member list. This page recreates each of those functions inside
`/incidents/2026-WA-Chelan-Hills-Fire-Entrapment/contributions/` using only files and one
scoped bearer token. There are NO user accounts anywhere in the design.

The substitution table:

| Dashboard concept | Recreated as |
|---|---|
| Account / login | Credited pseudo-identity: a free-text credit name plus a device pseudo-id (acequia `userId` when the browser has one, else `local-<8hex>` in localStorage) plus a `tok:<sha256-prefix>` fingerprint. The ballot.js identity block, reused verbatim in spirit. |
| Permission to write | One shared scoped capability: the embedded public-by-design key, write-only to `contributions/*`. Everyone who can see the page holds the same single power: append a file into this one box. |
| Membership / who's here | The public invite log at `contributions/invites/`, rendered as a lineage tree. Being invited creates a record, never an account. |
| Audit trail | The assertion log at `contributions/assertions/`: every contribution is its own credited, timestamped file, listed in full on the page. Append-only by filename convention, not by server enforcement: the shared key can PUT any filename in the box, so overwrite-denial was never verified and the moderator's owner-token audit is the backstop. |
| Per-user settings / scopes | Declared blast radius inside each assertion envelope (intent on record), with exactly one enforcement line in v1 (see section 4). |
| Admin panel | The wallet row plus one revoke command. Moderation is the bead's step that drains the log (section 6). |

Everything is files. Identity is credit plus lineage. Capability is one bearer. That is the
whole model, and the page will say so in plain words.

Mechanics reused from proven parents:

- **ballot.js** (incident-add fan, bead `019f8680`): the write ladder (visitor's own acequia
  identity first, the shared scoped key only after a 401/403, never on transient errors), the
  pseudo-identity block, every-cast-is-its-own-record filenames, the
  `X-Directory-As-JSON` listing with PROPFIND fallback, and the fingerprint rule (the
  envelope records `tok:<hash>` for attribution and NEVER a raw bearer).
- **set-space-time timeline.js** (santafe.live): the drop gesture that writes `calendarDate`
  (`"YYYY"` or `"YYYY-MM-DD"`) onto an item. Here the drop emits a calibration assertion
  instead of mutating anything: dragging an item's dot on the incident timeline (or a marker
  on the taos-engine view) produces a `kind: "calibration"` envelope pointing at the
  assertion it calibrates. The record stays append-only; calibration is testimony about time
  and place, layered over the log, never an edit of it.

The story spine itself (left column, with the persistent taos-engine view on the right) is
read-only and sourced ONLY from notes 00 and 01. Community assertions render in a visually
distinct contribution log beneath and beside the spine, labeled as community testimony,
credited, and never interleaved into the factual record as if they were part of it.

## 2. Assertion envelope schema

One file per assertion, append-by-convention, written to:

```
contributions/assertions/<iso-ts>-<slug>.json
```

where `<iso-ts>` is the write timestamp with `:` and `.` sanitized to `-` (the ballot.js
filename convention, which makes re-contribution append rather than overwrite) and `<slug>`
is `sanitizeKey(credit_name || pseudo_id)` plus the kind, e.g.
`2026-07-23T19-42-07-113Z-maria-orondo-gps.json`.

Envelope (JSON Schema, draft-07):

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Chelan Hills contribution assertion envelope, v1",
  "type": "object",
  "required": ["id", "ts", "from", "kind", "blast_radius", "sig"],
  "properties": {
    "id": {
      "type": "string",
      "description": "Unique assertion id (uuid); also derivable from the filename."
    },
    "ts": {
      "type": "string", "format": "date-time",
      "description": "ISO 8601 UTC moment the assertion was WRITTEN."
    },
    "from": {
      "type": "object",
      "required": ["credit_name", "pseudo_id", "fingerprint"],
      "properties": {
        "credit_name": {
          "type": "string",
          "description": "The name the contributor asks to be credited as. Free text; shown in the public log. Credit is a promise the page keeps."
        },
        "pseudo_id": {
          "type": "string",
          "description": "Device pseudo-identity: the acequia userId when the browser has one, else local-<8hex> persisted in localStorage (ballot.js localPseudoId). The dedup and lineage key."
        },
        "fingerprint": {
          "type": "string", "pattern": "^tok:[a-f0-9]{10}$",
          "description": "sha-256 prefix of pseudo_id, rendered tok:<10hex>. An attribution fingerprint, NEVER a bearer token; no raw token ever appears in an envelope."
        },
        "invite_id": {
          "type": ["string", "null"],
          "description": "The invite the contributor arrived through (read from #invite=<id> on the link they followed and remembered in localStorage), or null if they came in the open door. This is the token-propagation lineage hook."
        }
      }
    },
    "kind": {
      "enum": ["narrative", "gps", "imagery", "calibration"],
      "description": "What is being asserted."
    },
    "body": {
      "type": "string",
      "description": "kind=narrative: the testimony, witness grammar. The page prompts for what the contributor saw, heard, or did, and never for drama."
    },
    "href": {
      "type": "string", "format": "uri",
      "description": "kind=imagery: a link to the image (the box accepts small uploads too, as sibling files). Paired with rights, always."
    },
    "rights": {
      "type": "string",
      "description": "Required whenever href (or an upload) is present: the license or permission the contributor asserts, e.g. 'my photo, CC-BY-4.0' or 'mine, public display ok'. No rights statement, no render."
    },
    "geo": {
      "type": "object",
      "required": ["lon", "lat"],
      "properties": {
        "lon": { "type": "number" },
        "lat": { "type": "number" },
        "precision_m": { "type": "number", "description": "Honest stated precision in meters." }
      },
      "description": "WGS84 point the assertion is about. Required for kind=gps; optional context for the others. Feeds the taos-engine view as a community layer, visually distinct from the record."
    },
    "datetime_asserted": {
      "type": "string",
      "description": "When the asserted thing HAPPENED (ISO 8601, partial precision allowed, e.g. 2026-07-04T15:3x guessed as 2026-07-04T15:30). Distinct from ts, which is when it was written."
    },
    "calibration": {
      "type": "object",
      "required": ["via", "calendarDate"],
      "properties": {
        "via": { "const": "set-space-time" },
        "calendarDate": {
          "type": "string",
          "description": "The set-space-time convention: 'YYYY' or 'YYYY-MM-DD' or full ISO. Written by the drop gesture (timeline.js onDrop), here as a new assertion instead of a mutation."
        },
        "confidence": { "enum": ["exact", "close", "guess"] }
      },
      "description": "Present for kind=calibration, or when the contributor pinned time/place on their own assertion using the set-space-time gesture."
    },
    "re": {
      "type": ["string", "null"],
      "description": "id of the assertion this one calibrates, corroborates, or replies to. Calibrations point here; the log stays append-only."
    },
    "blast_radius": {
      "type": "object",
      "required": ["declared"],
      "properties": {
        "declared": {
          "enum": ["public", "contributors", "rls-review"],
          "description": "The contributor's declared intended audience: public = render for everyone; contributors = intended for people in the invite tree; rls-review = offered toward the authoritative record's stewards."
        },
        "note": { "type": "string", "description": "Optional plain-words intent." }
      },
      "description": "DECLARED INTENT, recorded on the record. v1 enforcement is exactly one line: the shared key can write only into contributions/* and can touch nothing else. Read-side enforcement of declared radii is future access-layer work, and the page says so next to the selector."
    },
    "sig": {
      "const": "none-v1",
      "description": "No cryptographic signature in v1. Provenance = the append-by-convention log + the credited envelope + invite lineage. Honest label so a future signed v2 is distinguishable."
    }
  }
}
```

Rendering rule: the assertions directory IS the public contribution log. The page lists every
envelope in full (credit name, fingerprint, kind, body/href, declared blast radius, lineage),
newest first, with the same latest-per-contributor / sum-of-all-casts toggle ballot.js proved.
Envelopes with `declared: "contributors"` or `"rls-review"` still render in v1 (they are in a
public directory and the page never pretends otherwise); they render WITH their declared
radius shown, so intent travels with the words.

## 3. Invite record schema and the chain

One file per invite, written to:

```
contributions/invites/<invite_id>.json
```

**The invite log is ALWAYS public.** Per Stephen, verbatim: "log the invites in the incident
outside of the blast radii." No declared radius applies to an invite record, ever; the
lineage of who brought whom into the testimony is itself part of the public record. The page
renders the log as a lineage tree (root = the page's open door, each node a credit name with
its note and timestamp).

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Chelan Hills invite record, v1 (always public, outside all blast radii)",
  "type": "object",
  "required": ["invite_id", "parent_invite_id", "from_credit", "to_name", "ts"],
  "properties": {
    "invite_id": {
      "type": "string",
      "description": "Short random id (e.g. 8 base36 chars). It is the filename stem and the fragment the invite link carries: <page-url>#invite=<invite_id>."
    },
    "parent_invite_id": {
      "type": ["string", "null"],
      "description": "The invite the INVITER themself arrived through, or null for a root invite issued from the open door. This field is the chain."
    },
    "from_credit": {
      "type": "object",
      "required": ["credit_name", "pseudo_id", "fingerprint"],
      "properties": {
        "credit_name": { "type": "string" },
        "pseudo_id": { "type": "string" },
        "fingerprint": { "type": "string", "pattern": "^tok:[a-f0-9]{10}$" }
      },
      "description": "Who extends the invite; the same credit block as assertions, so the tree and the log cross-link."
    },
    "to_name": {
      "type": "string",
      "description": "Who it is for, as the inviter names them (free text; no account is created; the invitee names themself when they first contribute)."
    },
    "note": {
      "type": "string",
      "description": "Why this person, e.g. 'was on McNeil Canyon Road that afternoon'. Public, so write it as a public sentence."
    },
    "ts": { "type": "string", "format": "date-time" }
  }
}
```

Flow: any contributor clicks "invite someone", names them, writes the note; the page writes
the invite record through the same shared key and hands back a link ending `#invite=<id>`.
When the invitee opens that link, the page stores the id, and every assertion they later cast
carries `from.invite_id`, joining them to the tree. The chain token in v1 IS this pair: the
public lineage record plus the shared scoped capability the page already embeds.

## 4. Exactly what the embedded key can and cannot do

One key, minted 2026-07-23 by `generate-api-token-for-app` (mint-only mode) via
`simtable-user-mint-only-token`, recorded in `.ai/acequia-wallet.md`:

- **tokenId:** `zsc8a6j5c330ppghlkho6fjd`, expiry 2027-01-20, PUBLIC-BY-DESIGN.
- **Scope:** write-only to
  `/incidents/2026-WA-Chelan-Hills-Fire-Entrapment/contributions/*` (both lanes,
  `assertions/` and `invites/`).
- **Verified blast radius, quoted from the wallet row:** "Blast radius verified: box PUT 201
  / collection-root PUT 403 / anon GET ok."

CAN: append JSON files (and small imagery siblings) anywhere under `contributions/`.

CANNOT: touch the story page's HTML, the RLS mirror, the STAC collection, the incident
record, `collection.json`, or anything else on the domain. The collection-root PUT 403 is
the proof, verified at mint time, before the key was trusted. It cannot mint children, and
it cannot read anything the public cannot already read.

The mint-only owner token never appears in any page, note, or envelope, this one included;
it exists only as a wallet entry and is used solely to mint and revoke.

Revoke line (the whole admin panel):

```
DELETE https://simtable.acequia.io/auth/tokens/zsc8a6j5c330ppghlkho6fjd
```

with the owner bearer; then re-mint and re-embed.

## 5. What v1 does NOT claim, and the named future

Said on the page in plain words, near the contribution form:

- **No per-person revocation.** Everyone spends the same shared key, so revoking it resets
  everyone at once. There is no way to cut off one contributor without draining the log
  (moderation) or re-minting (reset).
- **No read gating by blast radius yet.** `blast_radius.declared` is recorded intent. The
  directory is publicly readable, and the page renders accordingly. Nobody should write
  anything into this box they are unwilling to have public today.
- **No cryptographic delegation.** An invite proves lineage because the log says so, and the
  log is credited and append-by-convention (the shared key is not server-blocked from
  re-PUTting an existing filename; the moderator's owner-token audit is the backstop). It is
  a provenance chain. It is NOT a signed capability chain, and the page never calls it one.

Named future work, in order:

1. **Server-side mint proxy for per-invitee leaf tokens.** A small endpoint holding the
   owner credential server-side that, given a valid `invite_id`, mints a scoped leaf token
   (write-only to the same box, short expiry, one per invitee) via `POST /auth/create-token`.
   That upgrades the chain from provenance to real capability delegation: per-person
   revocation by `tokenId`, depth-bounded, auditable, exactly the attested-leaf shape the
   five rules already require.
2. **Read-side enforcement in the acequia access layer.** Teach `.acequia-access.json` (or a
   successor) to honor declared radii, so `contributors` reads require a token in the invite
   tree. Until that exists the page keeps saying "declared, recorded, and not yet enforced."
3. **Signed envelopes (`sig` moves past `none-v1`)** once contributors hold per-invitee
   tokens worth signing with.

## 6. Abuse surface, honestly

- **Worst case for the leaked key** (it ships in the page, so assume leaked from minute
  one): junk JSON and junk images in one directory. It cannot deface the page, alter the
  record, or roam the domain (section 4's verified 403). Recovery is the revoke line plus a
  re-mint, minutes of work, plus deleting the junk with the owner token.
- **Impersonation:** credit names are free text, so anyone can claim any name. The envelope
  therefore always displays the `tok:` fingerprint beside the name, and lineage gives
  context (an assertion from inside a known invite branch carries more weight). The page
  states plainly that credit is claimed, and corroborated only by lineage and consistency.
- **False testimony:** the spine is immune (read-only, sourced from notes 00/01, RLS
  credited as authoritative). Community assertions render as community testimony, labeled,
  never merged into the factual record. Calibrations are themselves assertions, so a bad
  calibration is one more log entry to weigh, never an edit.
- **Moderation:** the bead's recurring step drains the log: review new envelopes against the
  respect gate (witness grammar, no names of the entrapped beyond the RLS's practice, rights
  present on imagery), fold what holds into the bead's curated layer, and remove with the
  owner token anything that violates the gate, recording the removal in the bead's notes.
  The page links the box's README so contributors see the same rules the moderator applies.
- **Grief surface:** this incident is three weeks old and one civilian died. The
  contribution form's microcopy asks for what the contributor saw, heard, and did, with
  time and place; it never prompts for drama, blame, or names. That is a design control as
  real as the token scope.

Page skin notes (build phase): linked `redfish-style.css` where its dark ops palette fits,
font floor 15/14/13, one live accent, `prefers-reduced-motion` honored on the timeline and
any scripted scroll (the ballot.js REDUCED guard pattern), and the taos-engine view
persistent on the right per the program.

---
*2026-07-23 · bead `68d88c38` · author: Debbie (senior-uxui-designer `c4a8e2f1`) · design note, no build yet*
