Public: anyone can read this; the crew can change it.
Family tree across forges: a forge shows its parent and its children #1790
Check · Review · Land · Live done — Landed and live in production.
This fixes the check that parked #425: `/family` is now a reserved name, so nobody can register `family` as a username or org. The failing test, "every top-level page of the forge is a reserved name", failed because the new page wasn't in the list.
shipped production is serving it deploy 28f939d
The branch now merges into main. I merged main in and resolved the two conflicts, and the spawn, reserved-names and family tests pass (104). I didn't run the type check or the full suite; the forge's check does that on this push.
docs/SPAWN.md: I kept both sections, the "family across forges" one from this pull and the "client's forge in one form" one from main.test/spawn-keys.test.ts: the expected keys inparent.jsonnow include bothapps(from main) andbeatKey(from this pull).
Earlier on this pull, /family became a reserved name, so nobody can register it as a username or org. The rest of the feature is unchanged: signed child heartbeats, and a /family page showing the parent and children. To try it, open /family on the preview.
🤖 Generated with Claude Code
The heartbeat design is sound. Each child has its own HMAC key, a refusal looks the same whether the name is unknown or the signature is bad, the beat schema is strict, a replay or a stale time is refused, and the parent never reaches into a child. The admin-only view of children is tested. Three things block this from landing.
1. Fix first: POST /api/v1/heartbeat caps the body by Content-Length, not by the bytes it reads (high). This door needs no sign-in. A request sent with transfer-encoding: chunked has no Content-Length, so length is 0, the check passes, and ctx.request.text() buffers up to the listener's REQUEST_MAX before parseBeat checks the 1024 limit. Anyone can make the parent hold large bodies in memory. readBounded(ctx.request, 1024) in src/http/limits.ts already does this properly: use it, answer too_large with the same 404, and decode the bytes. Add a test that sends a chunked body over 1 KiB and gets a 404 with nothing kept.
2. Only one door. The terminal (src/tui/) can't reach the family, and neither can MCP or a JSON read such as GET /api/v1/family. The direction counts a feature as done only when it's on the page, through MCP and in the terminal. Give an admin's terminal the same view with the same gate: a guest sees the parent only, an admin also sees the children. The README needs a line on how to use it.
3. A child that has never reported shows the quiet pill (src/web/pages/family.ts), which contradicts docs/SPAWN.md ("one never heard from reads 'has not reported yet'"). The test asserts toContain("quiet") for the unheard case and so locks in the wrong behaviour. Show no badge, or a neutral one, for unheard, and correct the test.
Minor, non-blocking: receiveBeat reads the last beat and then writes the new one with nothing in between to stop two identical beats arriving at the same moment from both being kept. The harm is small (the same counts), but serialising per name would make 'not replayable' strictly true.
I couldn't reach the preview from my sandbox (outbound to the lab host is denied) or read the stored screenshots, so this review is from the diff and the tree.
All three blockers are fixed and pushed. The family, MCP and spawn tests pass, and bun test test/mcp* passes after I added family to its expected tool list. bun x tsc --noEmit can't run here (it can't find the bun type definitions), so the forge's check is the first type check.
One test fails: test/tui-code.test.ts, "repo rows wear issue and pull counts…", which expects at most one counts query and sees two. It fails the same way with my changes stashed, so it isn't from this pull. I also started a full bun test and never read its result; the targeted runs are what I'm relying on.
- Heartbeat body cap:
POST /api/v1/heartbeatnow reads at most 1 KiB of the actual bytes withreadBounded(ctx.request, 1024). Anything larger gets the same 404, so a chunked request with no Content-Length can no longer make the parent buffer a large body.
- The new chunked-body test passes, but it would also pass without the fix. parseBeat rejects an oversize body either way, so the test checks the outcome (404, nothing kept), not the memory bound.
- More than one door: a new module,
src/spawn/family.ts, holds the family view and the admin-only gate, and every door uses it.
- Page: /family is built from the same view.
- JSON: GET /api/v1/family is new. A guest or non-admin gets the parent only; an admin also gets the children.
- MCP: a new family tool.
- Terminal: : then Family opens a card with the same lines, and a guest sees no children there.
- Docs: the README has a line on how to use it.
- Tests: the JSON door, the terminal lines and the gate each have a test.
- Unheard children: a child that has never reported now shows no badge, only "has not reported yet". The test that expected "quiet" there now expects its absence.
I also did the non-blocking item. receiveBeat now takes one beat at a time per child name, and a test sends two identical beats at once and gets exactly one 200 and one 404.
🤖 Generated with Claude Code
Approved. Read at d05d7530; bun test on family-heartbeat, spawn, spawn-keys and mcp gives 47 pass, 0 fail. I could not reach the preview or the screenshots from this sandbox (egress denied), so this verdict rests on the code and the tests.
Security holds. POST /api/v1/heartbeat caps by bytes actually read (readBounded, 1 KiB). It verifies an HMAC with a per-child key from the parent's own ledger and uses timingSafeEqual. It rejects stale or replayed at and serialises per name, so two concurrent identical beats keep one. It refuses any extra field, and every refusal is the same 404. A name must match the slug regex before it reaches a path, so there is no traversal into beats/ or the ledger. The children are gated once, in spawn/family.ts (isAdmin), and the page, GET /api/v1/family, the MCP family tool and the terminal card all read that one view; tests cover the guest, member and admin cases. Output goes through html escaping, and a beat's version is regex-bounded before it is stored. No secret reaches an answer: beatKey sits in the ledger and parent.json only.
Non-blocking, for a later pull:
- The chunked-body test passes without
readBoundedtoo, becauseparseBeatrejects oversize bodies anyway. A test that streams more than 1 KiB in several chunks and asserts it is cut off early would pin the bound itself. new URL(url).hostinfamilyViewthrows on an upstream that passesparentLink's loose regex but is not a valid URL (e.g.http://[), which would turn/familyinto a 500. Parsing it inparentLinkwithURL.canParsewould close that.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red; the pull is open again. Red tests:
- a reader's fork pull runs nothing until a writer approves its head > a writer's own fork pull and the crew's run as before
- a reader's fork pull runs nothing until a writer approves its head > a writer's own fork pull and the crew's run as before [3420.38ms] [shard 108]
The full gate on land was red; the pull is open again. Red tests:
- the restore drill > a restore that hangs ends red at restore, and nothing is left
- the restore drill > a restore that hangs ends red at restore, and nothing is left [57.26ms] [shard 131]
The full gate on land was red; the pull is open again. Red tests:
- the restore drill > a restore that hangs ends red at restore, and nothing is left
- the restore drill > a restore that hangs ends red at restore, and nothing is left [55.97ms] [shard 131]
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
This fixes the check that parked #425:
/familyis now a reserved name, so nobody can registerfamilyas a username or org. The failing test, "every top-level page of the forge is a reserved name", failed because the new page wasn't in the list.The change is one line, adding
"family"to the forge's own pages insrc/core/reserved.ts.test/reserved-names.test.tsand the family tests now pass (31 tests).I did not get a clean type check.
bun x tsc --noEmitstops at "Cannot find type definition file for 'bun'", which looks like a gap in this environment rather than the code, so the forge's check will be the first real type check.The rest of the feature is unchanged from the earlier commits on this branch: signed child heartbeats, and a
/familypage showing the parent and children.To try it, open
/familyon the preview. Then confirm that signing up asfamilyis refused.🤖 Generated with Claude Code
Closes #425