~bigbes/sr-ht-dolt

aff726826a23dcaeeb8ed784e4dd562b04fddeb3 — Eugene Blikh 5 days ago 7d799ed
docs: spec the MCP surface and the second round of beads views
2 files changed, 705 insertions(+), 0 deletions(-)

A docs/DESIGN.mcp.md
A docs/DESIGN.views.md
A docs/DESIGN.mcp.md => docs/DESIGN.mcp.md +333 -0
@@ 0,0 1,333 @@
# dolt.sr.ht — the MCP surface (`mcpsrv/`)

A read-only Model Context Protocol endpoint on the web listener, so that an
agent reads a hosted Dolt database — and the beads tracker inside it — by
calling tools instead of scraping HTML.

This document is normative for the work; `docs/DESIGN.md` remains the service's
architecture document and is not superseded by it.

## 1. Why

Every beads tracker on this instance already has a companion Dolt database here,
and the browse UI already knows how to read one without a SQL engine. What is
missing is a machine-readable door to the same reading. Today an agent that
wants "what is ready to work in ~bigbes/sr-ht-artifacts" either runs `bd` in a
checkout it may not have, or fetches an HTML board and parses it. Both are worse
than the answer this service can already compute.

The family has settled this shape four times over — `sourcehut-coverage`,
`-specs`, `-bench` and `-artifacts` each ship an `mcpsrv/` package on
`github.com/modelcontextprotocol/go-sdk v1.6.1`. This service copies that
pattern rather than inventing a fifth one. `sourcehut-coverage/mcpsrv` is the
donor: it is the newest, and its package doc carries the reasoning the sections
below reuse.

## 2. What it must not become

The whole pure-Go build (`gms_pure_go`, no CGO, no ICU, no zstd) stands on one
fact: this service never starts the SQL engine. Bare NBS stores have no working
set, so they cannot be opened by `sqle` at all (`browse/open.go`). The MCP
surface changes nothing about that:

- **No SQL tool.** There is no `query(sql)` tool and there will not be one. It
  would require the engine, the engine requires a working set, and the working
  set is what a bare store does not have. An agent that needs an arbitrary
  projection reads rows and projects them itself.
- **No writes.** No `dolt_commit`, no issue mutation. Writing means a working
  set, a commit and a push — the dependency the build is deliberately free of.
  Mutation stays with `bd` in a checkout (and see `DESIGN.views.md` ch. 6, which
  gives a human the exact command to paste).
- **No second reading of any schema.** Visibility is `core.Allowed`, the beads
  fingerprint is the one in `beads/`, the row cap is the one constant. Two
  surfaces that each grew their own copy is how they start disagreeing quietly.

## 3. Placement and mounting

New package `mcpsrv/` at the module root, beside `browse/`, `web/` and
`remoteapi/`. It is mounted at `/mcp` on the **web listener** (there is no new
port): the daemon binds loopback and Traefik/nginx forwards, exactly as the web
UI is reached today.

`web.Register` claims `"/"` and wraps everything it mounts in the same-origin
CSRF group (`web/router.go`). `/mcp` must therefore be registered by
`cmd/doltsrht/main.go` on the router group **before** `web.Register`, and
outside that group:

```go
r.Handle(mcpRoute, agents) // "/mcp", registered before web.Register(r, cfg)
```

`Handle` and not `Mount`: the streamable handler serves the exact path, and
`Mount` would both rewrite the path to the empty remainder and claim `/mcp/*`
(this is the donor's note, `cmd/coversrht/main.go`).

The surface is bearer-only, carries no `Origin` and no cookie, and must not be
inside the CSRF group — the guard would refuse every call.

## 4. Identity

### 4.1 The credential

This service has three credential planes today: the unified-login cookie
(`authn/cookie.go`), a meta personal access token presented as HTTP Basic for
`dolt clone`/`push` (`authn/token.go`), and dolt's Ed25519 keypair JWT on the
gRPC side (`authn/jwt.go`). None of them is what an agent should carry.

The instance has a token service — `tokens.sr.ht` (`~/data/home/sourcehut-tokens`)
— whose whole purpose is this case: a long-lived parent token a human puts in an
agent's config once, exchanged for short-lived **working tokens** with narrowed
grants. `sr-ht-ecore/bearer` is the one copy of the validation, and
`sr-ht-ecore/grants` the one parser of the grant string; `cov.sr.ht` already
consumes both (`authn/instance.go`, `authn/resolver.go`). `sr-ht-ecore` is
already a direct dependency of this module, so adoption costs no new dependency.

**`Authorization: Bearer <token>` is the only machine credential `/mcp`
accepts.** No cookie plane here (an MCP client is not a browser), no Basic (that
is dolt's remote flow, and its username-must-match rule has no meaning without a
presented username).

Both bearer shapes the instance issues are `auth.BearerToken` sealed with the
same instance key, and only the `ClientID` tells them apart:

| ClientID | What it is | Gate |
|---|---|---|
| `tokens.sr.ht` | a tokens.sr.ht working token | must carry `dolt:read` (`grants`), plus the live check for a registered token |
| anything else | a meta.sr.ht PAT | `authn.TokenGrantsAllow(ac, core.AccessRO)`, the check the clone path already applies |

A token that fails verification is a **refusal**, never a downgrade to
anonymous. An absent `Authorization` header is anonymous, and anonymous is a
normal caller: it sees what anonymity may see. That is `authn.Resolver`'s rule
in the donor and it is this surface's too.

### 4.2 The grant vocabulary

`tokens.sr.ht` does not know any service's vocabulary by design — the service
declares it. Add to `core/`:

```go
// GrantRead is the tokens.sr.ht grant an instance working token must carry to
// read anything through /mcp.
const GrantRead = "dolt:read"
```

`dolt:write` is deliberately not defined: nothing on this surface writes, and a
grant nobody checks is a promise to an operator that no code keeps.

The grant is checked once, in front of the SDK handler, exactly as the donor
does (`requireReadGrant`): every tool on this surface is a read, so there is no
per-tool grant to derive. A meta PAT and an anonymous caller carry no
tokens.sr.ht grants and pass this gate; their access is decided by §4.3.

### 4.3 Visibility is not re-implemented

Every tool resolves the database through the same two calls the browse handlers
make (`web/router.go:loadRepoForBrowse`):

```go
repo := Repos.GetRepoByOwnerAndName(ctx, owner, name)
mode := Repos.EffectiveAccess(ctx, caller.UserID, repo.ID)
core.Allowed(caller, repo, mode, core.OpBrowse)
```

and answers a refusal the way the web does: a PRIVATE database the caller may
not see is **not found**, indistinguishable from one that does not exist
(`core.NotFoundForPrivate`). A tool that reported "forbidden" would rebuild the
distinction the 404 exists to erase.

`list_databases` enumerates only what the caller may *list* — an UNLISTED
database is absent from the listing and readable when named directly, which is
the rule the dashboard already implements.

## 5. Stateless, and why that is an authentication decision

The streamable HTTP transport runs **stateless**. The donor documents the
measurement against the same pinned SDK: in stateful mode the SDK binds a
session to the context of the `initialize` request, so every later `tools/call`
on that session answers as the principal who opened it — a call presenting *no*
credential, and a call presenting a *stranger's*, both read as the opener. The
session id becomes a bearer credential this service never meant to issue.

Stateless mode connects a temporary session per POST, so a tool handler's
context descends from the request that carried the call and the principal is
per call. The cost is the server→client half of the protocol: no standalone SSE
stream, `GET /mcp` answers 405. Every tool here is a read that answers in one
response; none samples, elicits or reports progress.

**The measurement is the donor's, not ours.** Port its identity test with the
matrix (no credential / stranger's token / owner's token) and re-run it here
against our own pinned SDK version, rather than citing this paragraph.

## 6. The Host allowlist

The SDK's DNS-rebinding guard refuses a request arriving on loopback with a
non-loopback `Host` — which is every genuine request in this deployment, since
the daemon binds `localhost:5307` behind a proxy that forwards the public host.
Disable it (`DisableLocalhostProtection: true`) and **replace** it in the same
constructor with an allowlist requiring `Host` to be this instance's own
hostname, derived from `[dolt.sr.ht]origin` via `instconf.OriginHost`. An empty
host from a malformed origin is a constructor error, never a guessed
`localhost`. Refusals carry the private-cache directives too.

## 7. Package shape

```
mcpsrv/
  mcpsrv.go     — Server, New, tool registration, the HTTP handler chain
  ports.go      — the seams this package calls (interfaces declared here)
  read.go       — the tool handlers and their argument/result shapes
  errors.go     — sentinel → tool result / protocol error mapping
  *_test.go     — in-process client over fakes; no Postgres, no store on disk
```

`ports.go` declares consumer-side interfaces, as `web/deps.go` does, and names
exactly what the tools call:

```go
type Repos interface {
    GetRepoByOwnerAndName(ctx, owner, name string) (*core.Repo, error)
    ListReposForDashboard(ctx, userID int) ([]*core.Repo, error)
    ListReposByOwner(ctx, owner string, viewer *core.Caller) ([]*core.Repo, error)
    EffectiveAccess(ctx, userID, repoID int) (*core.AccessMode, error)
}

type BrowseOpener interface {
    Open(ctx context.Context, diskPath string) (BrowseSession, error)
}
```

`BrowseSession` is the same method set `web` declares (`Branches`, `Log`,
`Tables`, `Rows`, `CommitSummary`, `Close`, plus `TableHash` from
`DESIGN.views.md` ch. 2). What is **absent** from these seams is the design made
structural: no `StoreManager` (nothing here creates or deletes a store), no
`UserResolver`, no ACL mutation, no key management. A handler cannot write what
its seam cannot reach.

Sessions are per call and closed by the handler (`defer sess.Close()`), which is
the browse discipline: a fresh manifest read per request, no stale cache.

## 8. The shared beads projection (`beads/`)

Both `/mcp` and the web views must read the beads schema the same way. Today
that reading lives in `web/beads.go` (1083 lines: the fingerprint, the lane
bucketing, the ready rule, the status categories, the dependency walk, the event
humanizer) where a second consumer cannot reach it.

**Extract it into a new root package `beads/`** before writing the tools:

- `beads/` depends on `browse/` and the standard library, nothing else. It is a
  projection: rows in, view model out, no HTML, no `net/http`, no `core`.
- Moves as-is: `Applies` (the fingerprint), `BeadCard`/`BeadIssue`/`BeadEdge`/…,
  the lane bucketing, the ready rule, `statusCategory`, the dep-tree walk,
  `humanizeEvent`, the filter model, the row helpers (`cell`, `indexCols`,
  `truthy`, `readRows`, `readRowsOptional`), the milestone rollup.
- `web/beads.go` keeps `beadsView` (the `View` implementation) and whatever is
  genuinely presentational; `web/milestones.go` likewise.
- The move is mechanical, and its test suites (`web/beads_test.go`,
  `web/milestones_test.go`, 900 lines together) move with it. A behaviour change
  smuggled into this commit is what makes the whole refactor unreviewable — the
  commit must be a pure move plus export renames.

This extraction is a prerequisite of both this document and `DESIGN.views.md`
ch. 4/5, and is the first phase in §11.

## 9. The tools

Names are `snake_case`; every schema is derived from a Go struct via the
generic `mcp.AddTool`, never hand-written JSON. A database is addressed as
`{owner, name}` (both without the `~`), which is what the URL says and what an
agent can copy from a link.

MCP has no per-resource tool registry: the tool list is static per server. The
beads tools are therefore always advertised and refuse per database — a database
whose tables do not carry the beads fingerprint answers "this database is not a
beads tracker", naming the generic tools as the way to read it anyway.

### 9.1 Generic (any hosted database)

| Tool | Arguments | Answers |
|---|---|---|
| `list_databases` | — | every database the caller may list: owner, name, visibility, description, default branch, head hash + commit time, `is_beads` |
| `list_branches` | db | branches with head hashes |
| `list_tables` | db, ref? | table names, columns (name/type/pk/nullable), row counts |
| `read_rows` | db, table, ref?, offset?, limit? | a page of rows as strings, plus the table total |
| `get_commit_log` | db, ref?, from?, limit? | commits: hash, author, date, message, parents |
| `get_commit_diff` | db, hash | the per-table summary `browse.CommitSummary` computes |

`ref` defaults to the repository's default branch (`browse.DefaultBranch`), so
an agent that does not care about branches never has to name one.

### 9.2 Beads-aware (a database whose tables carry the fingerprint)

| Tool | Arguments | Answers |
|---|---|---|
| `list_issues` | db, ref?, filter{status/category, type, priority, assignee, label, q, ready}, limit? | cards: id, title, type, priority, assignee, labels, blocked-by/blocks counts, ready, lane — **no long-text bodies** |
| `get_issue` | db, id, ref? | the whole issue: every modelled field, both dependency directions, the transitive trees, subtasks + rollup for an epic, comments, the merged history |
| `list_milestones` | db, ref? | the `milestone:<name>` rollups: totals, done/in-progress/open, members |
| `list_memories` | db, ref?, q? | `kv.memory.*` from `config`: slug, text, and the revision it was last written at (`DESIGN.views.md` ch. 2) |
| `ready_work` | db?, limit? | the ready set; **db omitted ⇒ across every beads database the caller may see** (the cross-database aggregation of `DESIGN.views.md` ch. 4, one implementation serving both surfaces) |

The list/detail split is deliberate and is this surface's version of the
family's "no source text" rule: a board of 78 issues carrying every description,
design note and acceptance criterion is the agent's context window spent on text
it did not ask for. Lists carry identity and metadata; `get_issue` carries
bodies.

### 9.3 Caps

- `read_rows`: `limit` ≤ 500, default 100.
- `get_commit_log`: `limit` ≤ 100, default 25.
- beads tools read at most `beads.Max` (2000) rows per table, the constant the
  board already uses, and report truncation in the result rather than paging.
- `list_issues`: `limit` ≤ 500, default 200, applied after filtering.

A cap that silently truncates is a lie to a caller that cannot see the table:
every truncated result carries `truncated: true` and the true total.

## 10. Configuration

| Key | Meaning |
|---|---|
| `[dolt.sr.ht]origin` | already required; also the `/mcp` Host allowlist |
| `[tokens.sr.ht]origin` | enables the working-token plane (revocation check). Absent ⇒ no such daemon on this instance ⇒ working tokens are refused, meta PATs and anonymity still work |

No `mcp-enabled` switch. A surface that is off in production and on in a test is
a surface nobody tests; `/mcp` exists wherever the daemon does.

## 11. Phases

1. **`beads/` extraction** (§8). Pure move, tests move with it, no behaviour
   change. Blocks everything else here and in `DESIGN.views.md` ch. 4–5.
2. **The bearer plane in `authn/`**: `ParseBearer`, the `tokens.sr.ht` plane
   over `sr-ht-ecore/bearer` (the `InstanceValidator` seam), the meta-PAT arm
   over the existing decode path, `core.GrantRead`. Table-driven classification
   tests, no network.
3. **`mcpsrv/` skeleton**: `New`, the Host allowlist, the grant gate, stateless
   transport, `list_databases` only. The identity matrix test lands here.
4. **Generic tools** (§9.1) over the browse seam.
5. **Beads tools** (§9.2) over `beads/`, minus `ready_work`'s cross-database
   arm.
6. **Wiring in `cmd/doltsrht/main.go`** (§3) + `config.example.ini` +
   `README.md`.
7. **`ready_work` across databases**, after `DESIGN.views.md` ch. 4 lands its
   aggregator and cache.

Phases 4 and 5 are disjoint file sets over a committed §8 and a committed §3
skeleton, so they can run in parallel; 1→2→3 is serial.

## 12. Tests

The donor's suites are the model, and the ones worth naming here:

- **Identity matrix** (§5): no credential / stranger's token / owner's token,
  same session, asserting the answer follows the *call*.
- **Visibility matrix**: for each of PUBLIC/UNLISTED/PRIVATE × anonymous /
  stranger / grantee / owner, every tool either answers or reports not-found —
  never "forbidden", never a distinguishable failure.
- **Host allowlist**: the instance host passes, a rebinding host is 403 before
  the SDK is reached.
- **Grant gate**: a working token without `dolt:read` is 403; a meta PAT and an
  anonymous caller are not gated by it.
- **Caps**: a table larger than the cap answers truncated, with the true total.
- **In-process client over fakes**: no Postgres, no store on disk — the fakes
  `web`'s tests already carry are the pattern.

A docs/DESIGN.views.md => docs/DESIGN.views.md +372 -0
@@ 0,0 1,372 @@
# dolt.sr.ht — the beads views, round two

Six changes to the read-only beads surface: a one-column layout beside the
parade board, a view for the memories `bd remember` writes, a freshness line so
a stale page says so, a cross-database "what is ready" page, cross-database
issue links, and copy-ready `bd` commands on the detail pane.

`docs/DESIGN.md` remains the service's architecture document; `docs/DESIGN.mcp.md`
is the agent surface and shares this document's ch. 4 aggregator and its `beads/`
extraction (that document's §8, a prerequisite here too).

## 0. What stays true

- **Read-only.** No INSERT, no `dolt_commit`, no working set. The pure-Go build
  (`gms_pure_go`, no CGO/ICU/zstd) is affordable only because this service never
  starts the SQL engine, and nothing below changes that. Ch. 6 is the deliberate
  answer to "I can see it but cannot act": hand the human the command.
- **The host's clothes.** The board deliberately mimics todo.sr.ht — flat,
  square, hairline borders, monospace ids, no radius, no shadow, no gradient,
  colours from the CSS variables that mirror core.sr.ht's Bootstrap palette in
  both the light default and `prefers-color-scheme: dark`. Every new surface
  here is drawn in that same idiom. This is a design decision and not an absence
  of one: the page is one tab away from todo.sr.ht's own ticket list, and a
  second visual language inside the shared chrome would read as a different
  application bolted on.
- **One reading of the schema.** After the `beads/` extraction, the fingerprint,
  the lane rule, the ready rule and the status categories exist once and are
  called by the web views, by `/mcp` and by ch. 4's aggregator.

## 1. The stream layout (`?layout=stream`)

### 1.1 What and why

The parade board is four lanes side by side, each ~15rem wide with the row of
lanes scrolling horizontally. That is right when comparing lanes and wrong when
reading one: on a laptop the fourth lane is off-screen, on a phone the board is
a horizontal scroll of vertical scrolls, and a lane of 35 closed issues pushes
everything else out of reach.

Add a **one-column stream**: the same cards, top to bottom, under section
headers in parade order — Rolling → Lined Up → Stalled → Past Stand.

It is a **layout of the Beads view, not a fifth tab**. `?layout=stream` alongside
the existing filters, a `Board | Stream` toggle in the filter bar. Filters, the
ready toggle and the search box apply unchanged, links to a card are unchanged
(`?issue=<id>` wins over any layout, as it does today), and the tab bar does not
grow a fourth entry for what is one page in two shapes.

### 1.2 Data

`beadsView.Build` gains one branch. `BeadsData` carries `Layout string`
(`"board"` — the default and what an unknown value falls back to — or
`"stream"`), and in stream mode fills `Sections []BeadsSection`, which is
`BeadsLane` plus the two fields a section header needs:

```go
type BeadsSection struct {
    BeadsLane           // Name, Slug, Accent, Issues
    Collapsed  bool     // rendered inside <details> without open
    Note       string   // "" or a one-line hint, e.g. "closed, newest first"
}
```

Lanes and sections are the same bucketing over the same filtered set: one issue
lands in exactly one of them, and the counts in the marquee are the counts in
the section headers.

### 1.3 Inner sorting

The board sorts every lane the same way (priority, then `created_at`, then id).
A column that is read top to bottom can afford one sort per section, because
each lane answers a different question:

| Section | Order | Why |
|---|---|---|
| Rolling | `started_at` desc, then priority, then id | in-progress work: what was picked up most recently is what is actually being worked on |
| Lined Up | ready first, then priority, then `created_at` asc, then id | this is the "what can I take" section; the ⚡ ready set leads it |
| Stalled | blocked-by count asc, then priority, then id | one blocker away is nearer to moving than five |
| Past Stand | `closed_at` desc, then id | a log: the most recently finished on top |

`started_at` and `closed_at` are already read on the detail pane (`BeadIssue`);
the card model gains them as sort keys and does not display them. A row missing
the timestamp sorts last within its section rather than first — an unset value is
not a very old one.

Past Stand is **collapsed** (`<details>` with a summary carrying the count, no
`open`). It is the largest section, it is the least actionable, and a
`<details>` element needs no JavaScript. The other three are always open.

### 1.4 Markup

Reuse `.bead-row` verbatim — the card is the same card. New: `.beads-stream` as
a single column (`max-width: 52rem`) and `.stream-head`, which is `.lane-head`
with `position: sticky; top: 0` so the section name stays visible while its
issues scroll past. The 2px accent cap and the swatch come along, so a section
is recognisable as the lane it is.

```
┌ Search ────────────┐ ┌types▾┐ ┌prio▾┐ ┌who▾┐ ┌label▾┐ □⚡ready  [Filter]
                                              view: Board · (Stream)

┃ ROLLING                                                              10
  artifacts-46c.1   Бакеты и ключ в garage-стеке
                    P1  task  @Eugene Blikh  milestone:v0.5.0  🚧2  blocks 1
  artifacts-46c.4   artifacts and srht stacks both resolve 'garage' …
                    P1  bug   @Eugene Blikh  milestone:v0.5.0  🚧1
┃ LINED UP                                                             20
  ⚡ artifacts-734  Пакетные репозитории
                    P1  epic  milestone:v0.2.0  pkg  blocks 18
┃ STALLED                                                              13
  artifacts-46c.2   Стек srht: БД, конфиг, образ, Traefik, DNS
                    P1  task  milestone:v0.5.0  rollout  🚧2  blocks 1
▸ PAST STAND (35)
```

The `Board | Stream` toggle rebuilds the current query with `layout` replaced,
so a filtered board switches to the same filtered stream. That needs one
template func, `withQuery` (see ch. 7).

## 2. The Memory view

Bead `sr-ht-dolt-b08` already carries the shape of this view; it is repeated
here only where this document adds to it, and the bead's design section stands
for the rest.

`bd remember` writes into the beads `config` table as ordinary key/value rows —
key `kv.memory.<slug>`, value the memory text (verified against the `beads-global`
and `sourcehut-artifacts` companions). For a tracker, that is half the content,
and today it is visible only in the generic table browser, one line per memory,
mixed in with `compact_tier2_days` and `issue_prefix`.

- New view `web/memory.go`: `Name() == "memory"`, `Label() == "Memory"`,
  `Template() == "memory.html"`, registered from `init()` like the others, tab
  appearing **after Milestones** (registration order is tab order, and
  `views.go` preserves it).
- `Applies`: the beads fingerprint plus a `config` table carrying `key` and
  `value`. `Applies` sees table shapes and never rows, so a tracker with no
  memories still gets the tab and renders an empty state — the same contract
  Milestones has.
- `Build`: `config` rows whose key has the `kv.memory.` prefix, slug =
  key without it, sorted by slug (or by age, ch. 2.2). `?q=` filters by
  substring over slug + text; `?key=<slug>` renders one memory.
- Values are stored as typed, so they carry both real newlines and literal `\n`
  escapes that agents put in shell strings. Normalise both into paragraphs
  rather than dumping one blob.

### 2.1 The revision each memory was last written at — and why

A memory has no timestamp: the `config` row is `(key, value)` and nothing else.
So a two-month-old note about a service that has since been rewritten looks
exactly like one written this morning, which is how a stale memory keeps being
believed. The date is not absent, though — it is in the history. Dolt keeps
every commit, and `bd remember` commits with the message
`bd: remember (auto-commit) by <author>`.

Answer the question from the history rather than from the row:

```
MemoryRevision{Commit, Date, Author}  — when this key's value last changed
```

**The walk.** From the head of `ref`, newest to oldest, at most
`memoryWalkMax = 500` commits:

1. Take the content hash of the `config` table at the commit — `root.GetTable`
   then `(*doltdb.Table).HashOf()`, which is O(1) and reads no rows.
2. If it equals the hash at the newer neighbour, `config` did not change in the
   newer commit: skip, read nothing.
3. Otherwise read `config` at this commit (a tiny table — a dozen rows in every
   tracker checked) and compare each tracked key against its value at the newer
   neighbour. A key whose value differs was written **by the newer commit**;
   record that commit's hash, date and author, and stop tracking that key.

Cost is one table-hash lookup per commit plus one row read per commit that
touched `config` at all. The most active tracker on this instance has 225
commits three weeks in (measured today via `dolt_log`), so 500 is roughly two
months of headroom at that rate; a key not resolved inside the walk renders
"older than the last 500 commits" rather than a date the walk cannot support.
The commit message is **not** the signal — it is a claim by whoever wrote it,
and the table hash is the fact.

This needs one new browse primitive:

```go
// TableHash returns the content hash of a table at ref, and ok=false when the
// table does not exist there.
func (db *DB) TableHash(ctx context.Context, refStr, table string) (string, bool, error)
```

added to `browse/tables.go` and to the `BrowseSession` interfaces in
`web/deps.go` and `mcpsrv/ports.go`. It is computed only by the Memory view and
by `list_memories` — the board never pays for it.

### 2.2 Rendering staleness

Each memory shows `written <relative> · <short hash> · <author>`, the hash
linking to the existing commit page. Sort is `?sort=slug` (default) or
`?sort=age` (oldest first — the review queue). A memory older than 60 days
carries a muted `stale?` marker; the number is a default in one constant, not a
per-request knob, and the marker is a question rather than a verdict — some
memories are meant to be permanent.

```
Memory · 9 entries                          master · last commit 4 minutes ago
┌ Search ─────────────────┐  sort: (slug) · age                    [Filter]

  handoff-2026-08-12                    written 3 hours ago · a1b2c3d · bigbes
  State after the 2026-08-12 round. Supersedes the two 2026-08-10 handoff
  memories, which asserted …

  metered-link-calibration      stale?  written 71 days ago · 9f8e7d6 · bigbes
  Owner is frequently on metered mobile internet (ZeroTier to the lab, mobile
  uplink). Calibrate downloads …
```

## 3. Freshness in the header

Nothing on the board says how fresh it is. `bd` pushes with a 30-second debounce
and a pull is manual, so a board rendered from a store that stopped receiving
pushes yesterday is indistinguishable from a current one — and the whole page
reads as fact.

Add a shared partial `beadsHead` rendering `<branch> · last commit <relative> ·
<short hash>` in the header of the Beads, Milestones and Memory views, with the
hash linking to the commit page. Data is one `Log(ctx, ref, "", 1)` call, whose
`CommitInfo.Date` is already carried; the envelope in `handleView` gains a
`Head *browse.CommitInfo` field so every view gets it without each one asking.

A relative time needs a template func (`ago`, ch. 7). Absolute time in the
`title` attribute, so the exact stamp is one hover away.

## 4. The cross-database ready page

Seventeen databases on this instance carry the beads fingerprint (sixteen
per-project trackers and the global one). "What is ready to work" is answerable
in each of them and nowhere across them, which is the question the split into a
global tracker plus project trackers was supposed to make askable.

**Route:** `GET /ready`, its own page (not a `View` — a `View` is a rendering of
one repository). Linked from the dashboard.

**What it does:** for every database the caller may browse, open a browse
session, check the fingerprint, and if it holds, collect the ready set (open,
unblocked, not template/ephemeral — the rule in `beads/`, not a second copy).
Group by database, order groups by ready count desc then name, and inside a
group by priority then id. Filters: `?q=`, `?assignee=`, `?priority=`,
`?db=<owner>/<name>` (repeatable).

**Cost, and how it is bounded.** N stores opened per request is exactly what the
per-request browse discipline does not scale to. Three bounds:

1. **A head-hash gate.** Opening a session and calling `Branches` is cheap;
   reading and projecting `issues` + `dependencies` is not. Cache per database
   keyed by `(repoID, head hash)`: when the head has not moved, the cached
   projection stands, no rows are read.
2. **A TTL.** Entries expire after 60s regardless, so a store rewritten under
   the same head (it cannot be, but the cache should not depend on that) heals
   on its own.
3. **A ceiling.** At most `readyMaxDatabases = 64` databases per request, and a
   page that hit the ceiling says so. A silent cap reads as "that is
   everything".

The cache is a small `sync.Mutex`-guarded map in the handler's state, sized by
entry count, not a new subsystem — and it holds a *projection* (the ready cards),
never a `browse.DB` handle: an open store is a file handle and a memory mapping,
and this is precisely the read pattern the per-request open exists to avoid
hoarding.

**The aggregator is shared with `/mcp`.** `ready_work` (`DESIGN.mcp.md` §9.2)
with no database named is this same function; the page and the tool differ in
rendering only. Visibility is `core.Allowed`/`OpBrowse` per database, applied
before a store is opened.

## 5. Cross-database issue links

A global-tracker issue that says "blocked by `artifacts-nex.2`" is naming a row
in another database, and the reader has to know which one and go there by hand.

**Recognise `<prefix>-<suffix>` in rendered text** — descriptions, design,
acceptance criteria, notes, comment bodies, event summaries — and link the ones
whose prefix belongs to a database on this instance:

- **The prefix index.** Every beads database stores its own prefix in
  `config` under `issue_prefix` (verified: `global`, `artifacts`, …). Build an
  index prefix → repository, warmed lazily and refreshed on the same head-hash /
  TTL basis as ch. 4's cache, over the databases the *caller* may browse. A
  prefix belonging to a database the caller cannot see is not linked, and the
  page must not reveal that it exists.
- **The pattern.** `<prefix>-<suffix>` where prefix is a known one and suffix is
  `[0-9a-z]+(\.[0-9a-z]+)*` — the shape bd generates, including the `46c.2`
  subtask form. An id in the *current* database keeps linking to the current
  view, as it does today.
- **The rendering.** Long text is currently dumped into
  `<pre class="field-body">` as plain text. Linkification must **escape first,
  then wrap the matches**, building the result as a sequence of escaped segments
  and generated anchors and only then marking it `template.HTML`. Marking
  user-stored text as HTML and running a regexp over it is how a stored payload
  becomes a rendered one. A unit test with `<script>` inside an issue
  description belongs to this change.

## 6. Copy-ready `bd` commands

The page shows work and cannot change it, and that is the architecture, not a
gap to fill: a write path means the SQL engine, a working set, a commit and a
push — the dependency the build is deliberately free of.

What it can do is hand over the command. On the issue detail pane, a small block
under the header:

```
bd update sr-ht-dolt-b08 --claim
bd close  sr-ht-dolt-b08
```

- The block is `user-select: all` per line, so one click selects a whole command
  and nothing else. **No JavaScript, no clipboard API** — the one script in this
  service is a progressive-enhancement prefill on the keys page, and a copy
  button that needs JS to work at all would be the first piece of UI here that
  does.
- `bd` is run in a checkout, so the command names the issue and nothing about
  paths: this service does not know where the tracker is checked out, and
  guessing `~/data/home/<name>` would be inventing a fact about the reader's
  machine. The database name is right there in the breadcrumb if the reader
  needs to pick a directory.
- Commands offered follow status: an open issue gets `--claim` and `close`, an
  in-progress one `close` and `--status=open`, a closed one `reopen`. Nothing is
  offered that `bd` would refuse.

## 7. Shared bits

Three additions serve several chapters and land with the first that needs them:

| Addition | Where | Used by |
|---|---|---|
| `ago(t time.Time) string` — relative time, absolute in the `title` | `web/templates.go` funcs | ch. 2, 3 |
| `withQuery(url.Values, k, v) string` — the current query with one key replaced | `web/templates.go` funcs | ch. 1 (toggle), 2 (sort) |
| `TableHash(ctx, ref, table)` | `browse/tables.go` + both `BrowseSession` seams | ch. 2 |

## 8. Phases

1. **`beads/` extraction** (`DESIGN.mcp.md` §8) — pure move, tests move with it.
   Blocks ch. 4 and ch. 5; ch. 1–3 and 6 can precede it but are cheaper after.
2. **Ch. 1 stream layout** + `withQuery`.
3. **Ch. 3 freshness header** + `ago` (small, and it makes ch. 2's rendering
   free).
4. **Ch. 2 Memory view** + `TableHash` + the revision walk (bead
   `sr-ht-dolt-b08`).
5. **Ch. 6 copy-ready commands** (independent of everything above).
6. **Ch. 4 `/ready`** + the aggregator and its cache — after phase 1.
7. **Ch. 5 cross-database links** — after phase 6, whose prefix index it shares.

Phases 2, 3 and 5 touch disjoint files and can run in parallel once phase 1 is
committed; 4 depends on 3 only for `ago`.

## 9. Tests

- **Stream layout**: bucketing identical to the board for the same filters (one
  test asserting section counts equal lane counts), each section's order, an
  unknown `layout` value falling back to the board, the toggle preserving every
  filter.
- **Memory view**: prefix filtering, `\n`-escape normalisation, `?q=`, `?key=`,
  the empty state on a tracker with no memories, and the revision walk against a
  fixture history where one key is written, changed, and left alone for several
  commits — including the "not found within the walk" arm.
- **Freshness**: an empty database (no commits) renders the header without one.
- **`/ready`**: visibility per database (a PRIVATE tracker the caller may not
  browse is absent, not 403), the head-hash cache serving a second request
  without a second read, the ceiling reporting itself.
- **Cross-database links**: an id in a visible database links, one in an
  invisible database renders as plain text, `<script>` in a description stays
  escaped, an id-shaped string with an unknown prefix is left alone.
- **Copy block**: the offered commands follow the issue's status.