# The verification gate — what will check this repo

Local answers for Tell. The portable principles are in
[`making-a-repo-check-itself.md`](making-a-repo-check-itself.md), carried over verbatim from the
Recombinant Runner repo, where they were written after four verification passes were each run,
quoted in a commit message, and thrown away.

**That file is the principles; this file is Tell's answers.** Written at repo init, before there is
any code — which is the cheap moment to write it and the only moment the decisions are free.

---

## 1 · Status

**Written at repo init, when CI did not exist.** It said CI would land in Phase 1, because at the
time the repo was documentation and *"a workflow added today would either check nothing or fail on a
missing `etl/validate.js`."*

**Superseded in Phase 0.** That phase produced `src/`, so the stated condition was met a phase
early: there is real content to check, and the workflow goes green on its first run. Waiting anyway
would have been ceremony. See `docs/DECISIONS.md` D5.

**Live now:**

| | status |
|---|---|
| `.github/workflows/ci.yml` | ✅ PR + manual dispatch; runs both commands below |
| `etl/validate.js` | ✅ the data gate, no `--force` |
| `scripts/lib/harness.mjs`, `scripts/verify.mjs` | ✅ discovery runner |
| `verify-import-graph` | ✅ mutation-tested |
| `verify-no-runtime-fetch` | ✅ mutation-tested |
| `verify-cache-version` | ✅ mutation-tested |
| `verify-status-sync` | ✅ mutation-tested |
| `verify-period-join` | ✅ mutation-tested |
| `package.json` boundary (OS-0) | ✅ `"type": "module"`, zero deps |
| `verify-time-authority` | ⬜ Phase 2 — needs `src/time/` |
| `verify-hydrology-tiers` | ⬜ Phase 4 |
| `verify-artifact-tiers` | ⬜ P1 |

The gate is one command, and it is exactly what CI runs:

```sh
npm run check          # = node etl/validate.js && node scripts/verify.mjs
```

Under a second. **Report which you ran.**

---

## 2 · Tell gets the harness cheaply, and should not squander that

Halliatus and Recombinant Runner both pay a **compile step** before any check can import a module —
the source is TypeScript, `tsc` emits specifiers Node refuses, so `harness.mjs` exists largely to
build-and-rewrite before it can assert anything. That obstacle is the stated reason four earlier
verification passes were never kept.

**Tell is buildless ES modules (SPEC §6). That obstacle does not exist here.** A check can
`import()` a module in `src/` directly, today, with no build, no cache, no rewrite. The harness
shrinks to:

- a small assertion collector (`suite`, `defineCheck`, `runCheck`)
- a DOM/global stub for the few modules that touch `document` or `maplibregl`
- a JSON/GeoJSON loader

The rest of `harness.mjs` is answering a question Tell does not have. **Do not port it wholesale** —
copying the compile cache into a repo with nothing to compile is cargo cult, and it would make the
one genuinely good reason to adopt Vite later (SPEC §6's typecheck trigger) harder to see.

Structure, following *discover, don't register*:

```
scripts/
  lib/harness.mjs        # assertion collector + DOM stub + data loader. Written once.
  verify.mjs             # discovers and runs every verify-*.mjs in one process
  verify-<topic>.mjs     # ~10 lines each; adding a check is dropping a file in
```

`etl/validate.js` stays separate and stays exactly as CLAUDE.md invariant 2 specifies — it is the
data gate, it exits non-zero, and **it never gets a `--force` flag.**

---

## 3 · The checks Tell actually needs

The playbook's test: *would this bug announce itself?* If yes, skip the harness — the browser and a
manual load are cheaper. Every item below is a **silent** failure: the app renders, the console is
clean, and the output is wrong.

| Check | Guards | Why it is silent |
|---|---|---|
| `verify-time-authority` | Invariant 1 | Year arithmetic leaking outside `src/time/` works perfectly right up until the era rail is swapped for the stratigraphy column, which is the one thing the invariant exists to keep possible. Nothing fails in between. **Must strip comments before scanning** — the playbook's gotcha; a grep check once passed with the line it sought commented out |
| `verify-coord-order` | `[lon, lat]` vs `[lat, lon]` | data-contracts calls this "the single most common bug in this file format." A swapped pair puts Ur in the Indian Ocean and throws nothing. Assert every coordinate falls inside the declared bbox |
| `verify-band-refs` | `bandIds` resolve against `bands.json` | An unresolvable band id means a record silently never renders in any period. Invisible — it looks like sparse data, which is a *legal state* here (invariant 7), so it cannot even be spotted by eye |
| `verify-artifact-tiers` | Invariant 4 | A `cultural`-tier artifact rendered as a precise pin looks completely normal. It is a false claim about where an object was found, and only a check can see it |
| `verify-hydrology-tiers` | Proposed invariant 9 (`docs/HYDROLOGY.md` §3) | Same failure shape: a `reconstructed` channel drawn solid is indistinguishable from a surveyed one, and asserts precision the field lacks |
| `verify-no-runtime-fetch` | Invariant 5 | A stray third-party `fetch` works fine on a dev machine with a warm network. It breaks offline, breaks the PWA, and breaks when a museum API rate-limits — none of which show up in dev |
| `verify-cache-version` | Service-worker staleness | **The highest-value check in the list.** See §4 |
| `verify-status-sync` | `status.html` drifting from `ROADMAP.md` | A stale status page renders perfectly and reads authoritatively. It is the artefact people check *instead of* reading the repo, so a wrong one is worse than none |
| `verify-period-join` | `bandIds` derived from the union span instead of per period | Fabricates era membership no source claims — the record still validates and still renders. Mutating the ETL to use the union span invents Akkadian and Ur III on 12 real places |
| sw.js classic-script guard | An `import` added to `sw.js` | `package.json` sets `"type": "module"`, so Node parses it happily — but the browser registers `sw.js` **without** `{ type: 'module' }`, so registration fails at runtime, is only `console.warn`'d, and the PWA silently stops working while every local check stays green |

Every one of these must be **earned by mutation** before it is trusted: break the thing on purpose,
confirm *which* assertions fail. A check kept because it passes has told you nothing.

---

## 4 · The two traps this repo is pre-built to spring

Both are the same failure the source repos got bitten by. Both are avoidable at zero cost right now
and expensive to retrofit.

### `railWeight` must be DERIVED, never hand-written

`docs/data-contracts.md` defines `bands.json` with `railWeight` values described as *"placeholder
values to be corrected with real counts"* after the Phase 1 ETL.

**That is exactly the trap in `verification-gate.md` §4** — a prose copy of a value that lives
somewhere else, which drifts silently the moment the owning data changes. In the source repo it was
a hard-coded row/group count in an e2e spec; every stage-manifest change invalidated it, and the
tempting fix was to nudge the number until it passed.

The era rail's whole design premise is *width proportional to data density* (SPEC §3.1). A stale
`railWeight` does not error — it just quietly makes the rail lie about the data, which is the one
thing the rail is for.

**So: `etl/normalize.js` computes `railWeight` from actual band membership in `places.geojson` and
writes it into `bands.json`.** Hand-editing it is then a mistake with a name. Mark the file as
generated:

```json
{ "_generated": "etl/normalize.js — do not hand-edit; railWeight is derived from places.geojson" }
```

Per `verification-gate.md` §5: *before editing any data file, ask what writes it.* A `_generated`
key at the top is how the next session finds out without having to already know.

### `CACHE_VERSION` and `data/` must move together

CLAUDE.md already says *bump `CACHE_VERSION` on every `data/` change, or users pin stale datasets.*
That is a rule stated in prose, which the playbook is blunt about: prose rules are followed until
the one session that is in a hurry.

The failure is maximally silent. **Your** browser is fine — you hard-refreshed. Every returning user
is served the old data by a service worker doing precisely its job. Nothing is red anywhere.

**Implemented differently, and better, than this section originally proposed.** The first design
diffed `data/` against the trunk, which needs git history, a base ref and the right `fetch-depth` —
three things that differ between a laptop, a PR runner and a shallow clone.

Instead `CACHE_VERSION` *ends in a content fingerprint of `data/`*, and `verify-cache-version`
recomputes it:

```js
const CACHE_VERSION = 'tell-v0.1.0-p1-e57d50c7b1';   // suffix = sha256(data/)[0..10]
```

Change `data/` without updating the suffix and CI fails **printing the correct value**. No git, no
base ref, identical behaviour everywhere. It also asserts every `SHELL` path exists, because
`cache.addAll` is all-or-nothing — one bad path and the worker never installs for anyone.

---

## 5 · The workflow, when Phase 1 lands it

Copied from Halliatus, whose reasoning applies here unchanged — **`venkatron/Tell` is private, so
runner minutes are metered.**

- **Separate from deploy.** Cloudflare Pages' Git integration builds and publishes every push on its
  own. Never fold checks into it. The playbook's §2 is the sharp version: *a deploy succeeding proves
  the thing assembles; it does not prove the code is correct.* For Tell that gap is total — the build
  command is **empty**, so a green Pages deploy proves only that files were copied.
- **Trigger:** `pull_request` + `workflow_dispatch`. The PR is the moment a result is worth reading.
- **Concurrency:** group on the ref, `cancel-in-progress: true`.
- **Node 22**, matching both source repos. Pin it in `.node-version` so local and CI agree.
- **No `npm ci` step, and no `package.json`, unless something actually needs a dependency.** The ETL
  and the checks are plain Node. A lockfile added out of habit is a maintenance surface bought for
  nothing, and it quietly undermines invariant 8.

```yaml
name: CI
on:
  pull_request:
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  check:
    name: validate · verify
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }   # verify-cache-version diffs against the trunk
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: node etl/validate.js
      - run: node scripts/verify.mjs
```

**Simulate locally before writing that file** — same order, same working directory, clean tree:

```sh
git clean -ndx          # confirm nothing untracked is load-bearing
node etl/validate.js && node scripts/verify.mjs
```

Target: **under 10 seconds.** There is no install, no compile and no browser, so anything slower is
a bug in a check. Speed is the feature — a gate you think about before running is a gate that gets
skipped.

### What each check would catch

The playbook's §2 exercise, and the reason the table in §3 is worth the effort. Finish the sentence
*"if this goes red, the thing that broke is ___":*

| check | if red, what broke | blind to |
|---|---|---|
| Cloudflare Pages deploy | file copying | **everything** — the build command is empty |
| `etl/validate.js` | the shipped data | all application code |
| `scripts/verify.mjs` | an invariant | rendering, and anything needing a real map |
| *(no check yet)* | — | **does the map actually draw?** See §7 |

---

## 6 · Repo hygiene, decided now

- **Turn on auto-delete of merged head branches.** One setting; the rogue-branch problem then stops
  recurring instead of being periodically swept. The source repo reached thirteen branches without
  it.
- **Classify before deleting — merged is not stale.** Of those thirteen, six were merged and safe;
  six were stale but **48–59 commits ahead**. Age-based cleanup would have looked entirely
  reasonable and destroyed hundreds of commits. Delete on *merged*; leave the rest for a human.
- **One session per working tree.** Concurrent agent sessions in one checkout produced failed
  string-replacements, a commit that swept up half-finished work, and divergent branches to
  reconcile by hand.
- **Verify the forge is reachable before diagnosing hygiene.** The playbook's §1: a repo that looks
  uniformly neglected is usually missing a credential, not discipline. *Uniformity is the tell* —
  sporadic neglect is patchy; a hard blocker produces a clean absence.

---

## 7 · Known gap, stated rather than hidden

**Nothing above renders a map.** Every check here is data-shaped or source-scanning, and the app's
actual P0 acceptance criteria are visual: markers persist across the flat/globe toggle, band changes
repaint within 200ms, the empty state appears instead of a blank canvas.

That is the same blind spot that produced the incident in `verification-gate.md` — a unit suite went
620/620, was reported as "verified", and broke the one spec that rendered the thing. **620/620 was
true and it was not verification of that change.**

Tell's equivalent will be the globe projection. CLAUDE.md already names it: *"Globe projection
failing silently on older Safari is the most likely regression."* Nothing in §3 can see that.

Two honest options, and the choice is deliberately deferred to Phase 5 rather than made now:

1. **Keep manual deploy verification as the gate** and treat CLAUDE.md's checklist as the contract.
   Cheap, and adequate for a solo project — but it decays the moment a session is in a hurry.
2. **Add Playwright for a smoke subset only** — map initialises, both projections, one band change.
   This buys a `package.json` and a browser download, so it must be a deliberate purchase. Note that
   e2e in the source repo grew to ~10 minutes, which is *past* the "fast enough not to think about
   it" line, and that is precisely how the habit of running only the fast pair formed.

Until one is chosen, **say which was run.** A report that says "validate and verify passed, no
browser check" is worth more than one that says "verified."
