From aff726826a23dcaeeb8ed784e4dd562b04fddeb3 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 12 Aug 2026 23:32:54 +0300 Subject: [PATCH] docs: spec the MCP surface and the second round of beads views --- docs/DESIGN.mcp.md | 333 ++++++++++++++++++++++++++++++++++++++ docs/DESIGN.views.md | 372 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 705 insertions(+) create mode 100644 docs/DESIGN.mcp.md create mode 100644 docs/DESIGN.views.md diff --git a/docs/DESIGN.mcp.md b/docs/DESIGN.mcp.md new file mode 100644 index 0000000000000000000000000000000000000000..7594e9a6448957bc90378fe161fc10c04009e1ac --- /dev/null +++ b/docs/DESIGN.mcp.md @@ -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 ` 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:` 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. diff --git a/docs/DESIGN.views.md b/docs/DESIGN.views.md new file mode 100644 index 0000000000000000000000000000000000000000..10ec782f6d5a0b6d7e9b262f1b0f77b685242c6b --- /dev/null +++ b/docs/DESIGN.views.md @@ -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=` 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
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** (`
` with a summary carrying the count, no +`open`). It is the largest section, it is the least actionable, and a +`
` 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.`, 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=` 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 `. + +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 · · `, 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 ` · last commit · +` 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=/` (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 `-` 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.** `-` 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 + `
` 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 `