Authoring hubs
A hub is a markdown file whose frontmatter anchors sentences (“claims”) to the code they describe. This guide covers writing claims, the anchor grammar, choosing the right granularity, and the verify loop. For the end-to-end first run, see the Quickstart.
Anatomy of a hub
Section titled “Anatomy of a hub”---summary: How auth refresh rotation works.anchors: - claim: refresh rotation is single-use; reuse triggers global logout at: src/auth/refresh.ts > rotateRefreshToken hash: 9b1c33ade8f1 # written by `surf verify`, not by handrefs: []---
# Auth
Prose a human (or agent) reads to understand this domain.claim— one sentence stating an invariant. Write what must stay true, not how the code is structured. A claim that restates the implementation rots as fast as a comment.at— the anchor: where the claim’s logic lives (grammar below).hash— the seal. Absent until yousurf verify; the gate treats a hashless claim as unverified.
Where hubs live is configured by the hubs glob in surf.toml (default hubs/*.md); keep them
central or co-locate them with code (["**/_hub.md"]).
Bootstrapping with surf suggest
Section titled “Bootstrapping with surf suggest”Authoring claims by hand is the main adoption cost. To get a head start, point surf suggest at
your source and it lists the top-level public functions no hub anchors yet, as a copy-pasteable
starter hub:
surf suggest "src/**/*.ts" # or --format json for toolingIt only suggests — it never writes a file or stamps a hash. Paste the block into a hub (or
surf new <name>), write a real claim sentence for each anchor you keep, delete the rest, then
surf verify. Treat it as a checklist of undocumented surface, not a mandate to anchor everything
(see granularity below).
The anchor grammar
Section titled “The anchor grammar”An anchor is a file path, then a >-separated symbol path:
src/service.ts > TokenService > rotate- One segment points at a top-level symbol:
src/m.rs > parse_anchor. - Nested segments walk into scopes: a type and its
impl/methods share a name, soTypealone may be ambiguous whileType > methodis unique. - Non-callables anchor too, not just functions: in Python, module constants, type aliases
(
X = Literal[...],type X = ...), and class attributes (Class > attr); in Rust/Go,const/static/varitems. Anchor the value whose drift the sentence is about. @Ndisambiguates genuine name collisions (1-based), e.g. two overloads:src/api.ts > handler@2.- Multiple sites — an
at:list combines its sites into one hash, so the claim is stale if any listed span changes:at:- src/a.rs > foo- src/b.rs > bar
Run surf lint to confirm every anchor resolves to exactly one symbol. Ambiguous or vanished
anchors block; a symbol that was merely renamed — or a file that git reports has moved — only
warns and points you at surf verify --follow.
Choosing granularity
Section titled “Choosing granularity”This is the central tension (proposal §8):
- Under-anchor → real drift slips through, because the changed logic wasn’t anchored.
- Over-anchor → every incidental edit re-triggers verification, and humans start
rubber-stamping
verifywithout reading — which defeats the tool.
surf lint emits advisory warnings (never blocking) to nudge you toward the middle:
- Near-whole-file span — the anchored symbol covers most of its file. Anchor a narrower symbol so unrelated edits don’t trip the claim.
- Too many anchors in one hub — split the hub; a long verify list invites rubber-stamping.
- Uncovered public function — a public function in a file the hub already anchors has no claim. Either add one, or accept it as intentionally undocumented.
Rule of thumb: anchor the smallest symbol whose logic the sentence is actually about.
If a claim sits on a large symbol where user-facing copy changes often, set ignore_literals: true
on it — string-literal content is then excluded from its hash, so a copy tweak no longer
re-opens the claim while logic edits (operators, numbers, structure) still do. Prefer a narrower
anchor first; reach for ignore_literals when the span genuinely must stay coarse.
anchors: - claim: the engine emits one result row per fixture at: src/engine.ts > computeResults ignore_literals: trueThe verify loop
Section titled “The verify loop”surf verify is the human escape hatch: it re-seals a claim after you confirm the prose still
holds, writing the hash into the frontmatter (and touching only that line).
surf check # DIVERGED? a claim's anchored logic changed# re-read the claim:# still true → surf verify [<at>] (re-seal)# now false → fix the prose first, then verifysurf verify --follow # renamed symbol OR moved file: re-point the anchor and re-hashVerifying without reading is the failure mode the whole tool exists to prevent. A green gate promises only “nothing anchored changed since last sign-off” — never that the prose is true.
Hubs and AGENTS.md
Section titled “Hubs and AGENTS.md”Hubs are declarative domain briefings; AGENTS.md is imperative operating instructions for
coding agents. Keep them separate — don’t copy hub prose into AGENTS.md. Instead, give
AGENTS.md a pointer block that sends agents to the hubs directory to search for what they need:
<!-- surf:hubs -->Context lives in [`hubs/`](./hubs/) — read only the hub(s) you need.<!-- /surf:hubs -->When that block is present, surf lint checks it links the configured hubs directory and that
the directory exists. It deliberately does not enumerate individual hubs — that would push an
agent to read everything instead of the one hub it needs.
See also: CI integration · Examples.