History showed what an agent run CHANGED. What it read lived in a daily aggregate with no session dimension, so the two could not be joined and nobody could answer "when my agent answered, what did it look at — and was it the fresh version or archive/retired-spec.md?". The join is one string carried through four places: hook -> spool -> hub -> run card. A run card now marks each change the run also read, lists the files it read and never touched, and says on screen why a read can be missing. The three landmines the issue asks be named here: 1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to writes on the note string would let any member with write access forge a note that collides with a teammate's run card and hang their reads off it. Fixed by adding journal.Op.Session — set only by `bdrive sync --hook`, never by --note — and joining on that. The note stays settable and stays untrusted; the join simply never reads it. Op.Session is additive JSONL and, like Mtime, is never an input to Less or Replay, so replay determinism is untouched and older ops carry "". The read half has the same hole one step further on: POST /reads takes the session id from the CLIENT, so a member could report reads under a teammate's session and paint files onto their card. Every session row is therefore pinned to the ownsDevice-validated device, and the query requires ?session= AND ?device= together — a forged row can only be found under the forger's own device, which MayActAs guarantees is never somebody else's. 2. BUCKET CARDINALITY. Putting the session in the read_stats key would take a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger loads whole at boot and full-scans on every heat request, hub-wide — so it would slow the Dashboard for projects that never ran an agent. This is the escape hatch the spec itself names, taken up front: session rows live in their own read_sessions repo, outside ReadLedger.byKey. No read_stats PK migration, no change to the resident-row count, ?by=device byte-identical. They get their own retention (session_retention_days, default 30) which DELETES rather than folds — no heat total was ever derived from them. 3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session that read a file it then deleted shows a change with no read. That is by design, and the run card says so in its footer rather than leaving it to read as a bug. Privacy ruling, written into internal/webapp/reads.go before anything serves it: a session id appears only in History responses on the op that carries it, and as a ?session= filter INPUT. It is never enumerated — no listing endpoint, no session column in /heat output, nothing new in ?by=device. Also: PendingReads now dedupes on (path, session), not path alone. Two agent sessions on one device between syncs used to collapse into one event carrying whichever session flushed last — one session's reads silently credited to another. Tests: journal round-trip + Less-ignores-Session; the forge test (`sync --note "claude-code session <someone-else's>"` leaves Session empty); a multi-device syncer test carrying the session through convergence; spool per-session dedup; hub round-trip, cross-device forge, query contract and non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts grouping incl. legacy fallback; a Playwright spec on the seeded run card.
docs.beardrive.ai
The public product documentation: CLI, sync model, self-hosting. Astro + Starlight, static output, Pagefind search.
npm install
npm run dev # http://localhost:4321
npm run build # -> dist/
npm run preview
npm run check:sitemap http://localhost:4321 # after npm run preview
npm run check:sitemap https://docs.beardrive.ai # or against production
The sitemap
/sitemap-index.xml -> /sitemap-0.xml, generated by @astrojs/sitemap.
Starlight would add that integration itself; astro.config.mjs declares it
explicitly instead, which replaces Starlight's default rather than duplicating
it and is what makes the lastmod option reachable.
Each URL carries a <lastmod> taken from the commit date of the markdown
behind it — see "Checkout depth" below for the one way that goes wrong. The
redirect stubs are correctly absent: Astro emits them as noindex meta-refresh
pages, and @astrojs/sitemap leaves them out.
public/robots.txt exists to carry the Sitemap: line: robots.txt is the one
file a crawler fetches without being told, and the landing page's robots.txt is
on a different host so it cannot point here.
Verify that file survives the deploy. This host is behind Cloudflare, which
was serving a managed robots.txt of its own (the content-signals policy block)
back when the origin had none — a body with no Sitemap: and, in fact, no
directives at all. Whether an origin robots.txt now replaces that or gets merged
with it is Cloudflare's call, not this repo's, so after deploying check the
served file rather than the built one:
curl -s https://docs.beardrive.ai/robots.txt | grep -i sitemap
check:sitemap is the same set of checks Search Console runs — follow
robots.txt to the index, parse it, confirm every advertised URL returns 200 —
and takes any origin, so it works against a local preview and against
production. It is worth running against production after a deploy: a sitemap
that has gone stale or started advertising 404s looks completely fine until
something crawls it, which is weeks later.
Why this is a standalone site
Unlike the hub frontend (internal/webapp/static) and the cloud landing page
(cloud/internal/landing/dist), this is not embedded into a Go binary:
- Docs change far more often than the binary. Embedding them would mean a Go rebuild and redeploy to fix a typo.
- A Pagefind search index has no business shipping inside every self-hoster's install.
It lives in the OSS repo because that is what it documents. "Edit this page" resolves to something an outside contributor can open a PR against.
Design tokens
scripts/tokens.mjs reads the @theme block in
internal/webapp/frontend/src/tw.css — the source of truth for the BearDrive
palette — and emits src/styles/tokens.gen.css. That file is generated,
gitignored, and regenerated by npm run dev and npm run build, so the palette
cannot drift. src/styles/custom.css maps Starlight's --sl-color-* variables
onto it and invents no colors of its own.
(The cloud landing page can't do this — it sits in a different Go module and
keeps a copy of the tokens, policed by its own check-tokens.mjs.)
Adding a page
Drop a .md file under src/content/docs/<section>/ with title and
description frontmatter, then add its slug to the sidebar in
astro.config.mjs. Sidebar order is explicit, not alphabetical.
Write description for every page: it is the meta description, the search
result snippet, and what llms.txt shows.
llms.txt
starlight-llms-txt generates /llms.txt, /llms-small.txt, and
/llms-full.txt at build time.
Convention puts llms.txt at the root domain, not a docs subdomain — so
beardrive.ai/llms.txt should redirect or proxy to docs.beardrive.ai/llms.txt.
That redirect lives in the cloud landing page and is the one cross-repo
coordination point this split introduces.
Structure
The sidebar order in astro.config.mjs is the recommended path, and the
recommended path is agent-first:
- Start here — what it is, set up with your agent, first hour. No
brew installappears in this group. - Working with agents — the workflows the product exists for.
- Manual setup (optional) — the CLI route: install, set up by hand, skills and hooks in detail. Same destination, more steps; one click away, never on the critical path.
- Use cases — job-shaped titles ("Share work across your team's agents"),
persona named in the first line and in the
description. These pages ROUTE: who it's for, what you get, the one setup difference, links out. The moment one starts teaching a feature, it links to the guide that owns it instead. - Self-hosting, Reference, Concepts — unchanged in intent.
Keep new onboarding content out of Manual. If a page teaches someone how to get started, it belongs in Start here and should say what to ask an agent, not what to type.
Deploying
Static output in dist/. Any static host works; build command npm run build,
output directory dist, project root web/docs.
Redirects
The docs were reorganized around the agent-first path, so three old URLs moved:
| Old | New |
|---|---|
/start/install |
/manual/install/ |
/start/quickstart |
/manual/setup-by-hand/ |
/guides/connect-an-agent |
/start/setup/ |
astro.config.mjs declares these, which in a static build emits meta-refresh
pages — fine for humans, weak for search engines. Real 301s belong in the host.
Firebase Hosting (simplest static option on GCP — CDN, TLS, and custom domains included):
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*"],
"redirects": [
{ "source": "/start/install", "destination": "/manual/install/", "type": 301 },
{ "source": "/start/quickstart", "destination": "/manual/setup-by-hand/", "type": 301 },
{ "source": "/guides/connect-an-agent", "destination": "/start/setup/", "type": 301 }
]
}
}
Cloud Storage behind an external Application Load Balancer: put the rules in the URL map, which redirects before the bucket is ever reached.
gcloud compute url-maps edit docs-url-map # pathMatchers[].pathRules[]:
# - paths: ["/start/install"]
# urlRedirect:
# pathRedirect: "/manual/install/"
# redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
# stripQuery: false
Whichever host wins, keep the Astro redirects block as well: it is the
portable fallback, and it keeps local npm run preview honest.
Note that the build reads a file outside web/docs (the token source), so
the host must check out the whole repository rather than just this
subdirectory.
Checkout depth
Check out with full history, not a shallow clone. The sitemap's <lastmod>
for each page is the commit date of the markdown behind it, so a depth-1
checkout — the default for actions/checkout and for most build hosts — has
nothing to read the dates from.
- uses: actions/checkout@v4
with:
fetch-depth: 0 # sitemap <lastmod> comes from commit dates
Getting this wrong degrades rather than breaks: astro.config.mjs detects the
shallow clone and emits no lastmod at all, because the alternative is
stamping all 25 pages with the one commit a shallow clone has, and a sitemap
that claims the whole site changed on every deploy is one Google learns to
ignore. So the symptom is a silently less useful sitemap — check for <lastmod>
in the deployed sitemap-0.xml after changing hosts.