# Design notes - why wiki-notify-gchat is built the way it is

Context for future devs: the constraints we hit, the dead ends we tried, and
the reasoning behind the current shape. Built 2026-07-16 by Drew + Claude in
one long iterative session, live against real mail and a real Chat space.
Read this before "improving" something - several obvious-looking alternatives
were tried and killed for non-obvious reasons.

## The problem, and the pivot

The original ask was a generic "forward matching Gmail to a Chat space and
delete the email" tool (`mail-gchat`, v1.0.0 - shipped and replaced the same
day). Per-message forwarding turned out to be the wrong model: it moves the
clutter from the inbox into Chat. What Drew actually wanted was a **standing
board**: open wiki issues stay in front of you until resolved, closed items
linger long enough to enjoy, then disappear (6am the next day). That pivot -
from a stateless forwarder to a stateful item board - drove every design
decision below.

## Google Chat constraints (the load-bearing discoveries)

These were all verified empirically against the live API; the errors are
quoted so you can recognize them.

1. **Incoming webhooks are create-only.** PUT/PATCH/DELETE with the
   webhook's `key+token` fail with `401 "API keys are not supported by this
   API"`. The docs confirm: webhooks post messages, full stop. So a
   "continuously updated card" cannot be updated in place via webhook.
2. **Only apps can send cards.** User OAuth (`chat.messages`) can create and
   edit its own *text* messages but cannot send `cardsV2` at all. So "post
   the card as the user and edit it" is also impossible.
3. **The escape hatch: delete-repost swap.** A user CAN delete the webhook's
   messages - including card messages - **if they are a space manager**,
   which they are, because they created the space. So every board change is:
   webhook POSTs the fresh card, then `adom-google` deletes the previous one.
   Net effect: exactly one board card, always the newest message in the
   space. This is why the setup docs insist each member uses a space *they
   created*.
4. **Trap: deleted messages still GET 200.** A deleted Chat message returns
   HTTP 200 with `deletionMetadata` set (a tombstone); re-deleting it returns
   403 "Permission denied ... or the resource doesn't exist". We initially
   misread this as "cards can't be deleted" and nearly redesigned around a
   nonexistent limitation. Check `deletionMetadata`, not the status code.

## Card layout - what we tried and killed

Chat cards have **no table widget**, the `columns` widget hard-caps at **2**
(extras silently dropped, no nesting), and text is a proportional font with
roughly 55-60 characters per line at desktop card width.

- **Single-line pipe table** (`STATUS | ITEM | TITLE | BY | OPENED | LAST |
  LINK`, one line per row): unreadable in practice - rows wrap mid-cell and
  columns smear together. Looked fine in JSON, mush on screen. Killed on
  sight.
- **Grid widget as a table**: `grid` officially supports any number of
  columns, but cells are equal-width tiles (TITLE gets the same width as
  STATUS), plain strings only (no color, no links), one click target per
  grid. We posted a live demo card with 5-col and 3-col variants; verdict
  was "gross". Killed.
- **Monospace code block in a text message** (would give true alignment):
  links are not clickable inside code blocks, no color, and it reads like a
  terminal dump. Never shipped.
- **What survived**: a 2-column `columns` widget per row with a `divider`
  between rows. Left: bold `page #N` with colored OPEN/CLOSED beneath.
  Right: description with the View link |'d at its end, then Created-by,
  last-event, and involvement lines. Column structure with *controlled* line
  breaks - a wrap can only happen inside the description, never across
  fields. The older per-field layouts remain as `style: "blocks"`.

## Gmail findings

- **Gmail search cannot match quoted URLs.** `"wiki.adom.inc"` returns zero
  hits and the bare keyword `wiki` matches URLs unreliably (we measured 5 of
  18 real notifications). Precision therefore comes from a broad sender
  query (`from:no-reply@adom.inc in:inbox`) plus a `requireInBody`
  substring check on the fetched body. Do not "fix" the query by quoting.
- **`adom-google api` prints its `HTTP <status>` line on stderr**, stdout is
  pure JSON. Our first parser assumed stdout and silently truncated the
  body.
- **Safe-mode scope is enough.** `gmail.modify` covers trash, archive, and
  labeling (and `messages.insert`, which is how the e2e test injects a
  synthetic notification). No full-mode auth needed.

## Email inference has a ceiling - the wiki DB is the authority

Two field bugs proved you cannot derive issue facts from notification emails
alone. (1) **Your own actions send you no email**, so for an issue you
opened, the earliest email you hold is someone else's comment - the board
showed "Created by Drew on Jul 20 11:01" for an issue actually created
Jul 16, stamping the creation with a commenter's timestamp. (2) **Footers
can contradict the wiki's database**: an email footer claimed "you opened
the issue" for an issue the DB attributes to someone else. The fix:
`adom-wiki issue list <page> --json` is fetched once per item
(`metaFetched`), and its `author_name` + `created_at` override every
email-derived guess; a footer-derived "opened this" involvement is dropped
when the DB disagrees. Email remains the *event stream*; the wiki is the
*record*.

## The involvement line (and why it needed two data sources)

The email footer ("You're receiving this because ...") is **issue-scoped**:
if someone else opens an issue on a page you *created*, the wiki only says
"you watch this page" - page creators are just auto-watchers in its
notification model. That made the board claim Drew merely "watches" pages he
authored. Fix: cross-reference `adom-wiki page stats <page>` `author_name`
(cached per page in state) against the user's `fullName`. Precedence:
owns this fix (assigned) > opened this > owns this page > was mentioned /
commented > watches this page. Related: the footer is *authoritative* about
self-involvement - "you opened the issue" beats our creator guess, which is
only "actor of the earliest email we hold" and can be wrong when the
creation email predates the retention window.

## Ops lessons (each of these bit us)

- **cron runs with a bare PATH.** Every scheduled pass failed for hours with
  `adom-google: not found` while the board looked healthy (manual runs kept
  feeding it). The tool now resolves helper CLIs from `~/.local/bin` /
  `/usr/local/bin` explicitly. Test scheduled code under
  `env -i PATH=/usr/bin:/bin`, not your shell.
- **Cold boots stop cron, and humans never notice.** Fix is `ensure`:
  restart cron via passwordless sudo, else run a detached watch loop
  (retired automatically when cron returns). Wired three ways by
  `install-cron` - cron `@reboot`, a `~/.bashrc` snippet, and a Claude Code
  `UserPromptSubmit` hook (the adom-hook pattern) - so recovery rides
  whatever the user does first. Never ship "run sudo when you notice"
  as the recovery story.
- **Concurrent passes are real** (cron + watch fallback + ensure). `run`
  takes a non-blocking flock; a second pass exits quietly.
- **Auto-update is not this tool's job.** adom-hook's throttled
  `adom-wiki pkg update` already refreshes every installed package. We
  explicitly did not build an updater.

## The road not taken: a central service container

Seriously considered: one always-on multi-tenant container polling for the
whole team ("always ready to update"). Rejected because this tool wields
**personal** credentials - it reads your Gmail, trashes your email, and
deletes messages as you. Centralizing means every member's Google refresh
token on one box; the per-user design keeps each token 0600 on its owner's
container. The resilience goals were met locally instead (self-healing cron
+ adom-hook auto-update). If you revisit this, know it was a deliberate
trade, not an oversight - the blocker is credential centralization, not
engineering effort.

## Testing approach

- `--dry-run` everywhere; `board --dry-run` prints the exact card JSON.
- Purge logic is unit-testable by importing the script
  (`SourceFileLoader("wng", "bin/wiki-notify-gchat")`).
- Full e2e without waiting for real wiki traffic: build an RFC822 message in
  the real notification format, inject it with Gmail `messages.insert`
  (`labelIds: ["INBOX","UNREAD"]`), run a pass under a cron-identical bare
  environment, then verify the board row and the `TRASH` label. Clean up by
  deleting the item from state and force-reposting the board.

## State model (quick orientation)

`~/.local/state/wiki-notify-gchat/state.json`: items keyed `page#num`;
events fold in with timestamp guards (listing order is newest-first, so
every mutation checks `ts` before overwriting); `processed` message-id map
dedupes ingestion; `lastRenderHash` (hash of items only, not timestamps)
prevents no-change reposts; `boardMsg` is the current card; `pageAuthors`
caches the ownership lookups. Purge drops closed items at `purgeHour` (in
the configured IANA `timezone` - containers are UTC, so leaving it unset
makes "6am" fire overnight in the US) on the day after `closedAt`.
