Instructions for Claude Code (and any other LLM agent) picking up work on this project.
ARCHITECTURE.md— the three-role model (network-anchor, service-anchors, backends) and how traffic flows. The mental map you need to read anything else correctly.SPEC.md— what anchord must do (acceptance criteria, testable).CONTEXT.md— why anchord is shaped the way it is (design principles, rejected alternatives).README.md— how a user encounters anchord (mental model first, then architecture, then config).
If a request to you contradicts SPEC or CONTEXT, surface that contradiction before writing code. Don't quietly route around the design.
Production. v1.0.0 tagged 2026-05-20, v1.2.1 cut 2026-05-25
(hairpin DNAT fix, issue #11). Running on a bare-metal TrueNAS SCALE
host (kernel 6.12.x, nft_fib_ipv6 loaded), 9 Compose stacks, ~30
anchord containers — Mailcow, Authentik, Nextcloud-AIO + Talk,
Frigate, Traefik, MeshCentral, Vaultwarden, Xibo, CUPS — under
real workloads (SMTP, IMAP IDLE, LDAP binds, OIDC, Nextcloud Talk
WebSocket signaling, RTSP camera streams). The README's
auto-generated TEST-REPORT block reflects the latest cut: 318 unit
- 74 e2e green, hash matched.
v2 pivot landed on main (2026-05-18): macvlan ownership moved from
anchord to Docker. anchord no longer creates a anchord-ext child or
needs sysctl/host-NS gymnastics; it joins an external: true Docker
macvlan network like any other container and runs only the L3/L4
surface (optional DHCP refresh, nftables DNAT/MASQUERADE). See
SPEC-v2-DRAFT.md plus the per-feature SPEC-*-DRAFT.md files
(F-39..F-46) for the contracts.
Functional surface as of v1.0.0:
- Network-anchor and service-anchor modes both implemented and
tested. Single binary,
ANCHORD_MODE=network-anchor(default) orANCHORD_MODE=service-anchor(orcommand: [service-anchor]). - Observability (SPEC §2.7 + §2.8) wired into both modes:
Prometheus
/metricsplus/healthz(liveness) and/readyz(readiness) on a single HTTP listener, defaultANCHORD_METRICS_ADDR=127.0.0.1:9090(loopback-only so the macvlan doesn't see it). - e2e harness lands at 70/70 across 5 DHCP scenarios (v4-only,
v6-only, both, none, dhcpv6-stateful) on Docker Desktop with
E2E_BRIDGE_FLOOD_FIX=1— a one-shotbridge-nf-call-iptables=0host-wide tweak only needed on Docker Desktop's WSL2 bridge; production Linux hosts don't need it. Combined with 97/97 unit tests, the auto-generated TEST-REPORT block in README is the release-readiness signal. - Two F-20 fixes landed on the way:
mainno longer treatscontext.Canceledas fatal (exit 1 on SIGTERM was wrong), and the dhcp goroutine is now awaited viaWaitGroupso its deferredremoveLinkactually runs to completion before main returns. - Test-report machinery:
scripts/code-hash.shproduces a deterministic SHA-256 over*.go,go.mod/go.sum,Dockerfile,test/,scripts/.scripts/update-test-report.shself-execs in Docker, runs everything, only writes README's TEST-REPORT block on green..github/workflows/release-gate.ymlblocks anyv*tag whose tagged commit is either off-main or whose recorded hash is stale.
Only outstanding gap before a v1 tag: real-host validation (run e2e
on a Linux host with a physical VLAN sub-interface, confirm 70/70
without E2E_BRIDGE_FLOOD_FIX).
DHCP is pure-Go as of 2026-05-02 (github.com/insomniacslk/dhcp via
internal/dhcp/dhcp.go); no more dhclient subprocess and no more
ISC-DHCP-EOL concern.
- Package layout:
cmd/anchordis the only binary entry point; it dispatches onANCHORD_MODE(or first non-flag argument) into either the network-anchor or service-anchor code paths.internal/*packages are leaf-shaped;reconcileris the only one that depends on multiple others. - Logging:
log/slogonly, JSON handler, structured key/value pairs. Nofmt.Printlndebug leftovers. - Errors: wrap with
fmt.Errorf("context: %w", err)at every layer boundary. Never discard. - Concurrency: one goroutine per long-running subsystem (dhcp, discovery, reconciler), coordinated via context cancellation. No goroutines that aren't tied to a context.
- Tests: table-driven,
t.Runsubtests. Public surface of every package needs at least happy-path coverage. Integration tests with real netlink/nftables live alongside the package they exercise (e.g.internal/nat/nat_integration_test.go) and are gated behind a build tag (//go:build integration). The privileged Docker driver to run them lives intest/integration/. - Comments: every exported symbol has a doc comment. Package doc comments explain the package's role in the system, not just what it is.
- Don't add a config file format. Environment variables only. (See CONTEXT.md "Configuration scarcity".)
- Don't add iptables paths. nftables only.
- Don't shell out to
nft,ipor other userland tools when a netlink Go library is available.conntrackis currently the only intentional subprocess dependency; the justification is in the Dockerfile. (DHCP is pure-Go viagithub.com/insomniacslk/dhcp; do not regress that.) - Don't add features that operate above layer 4. (See CONTEXT.md "Layer 4 hard stop".)
- Don't break the atomic-update guarantee on inbound NAT. (See SPEC F-19.)
A change is ready to merge when:
go vet ./...clean.go test ./...passes.- Touched code is covered by at least one test.
- If user-visible: README updated. If design-visible: CONTEXT or SPEC updated.
- Manual smoke test on real Docker:
docker compose upagainstcompose.example.yamlproduces a working NAT path. - The commit message explains why, not just what. "Fix DNAT rule" is bad; "DNAT rule needs explicit l4proto match for nftables ≥ 1.0.6" is good.
- Anything that touches the SPEC (functional or non-functional requirements).
- Adding or removing a CLI / environment variable.
- Choosing between two implementations with materially different tradeoffs.
- Any change to the Mental Model section of README.md.
When not to ask: routine refactoring, test additions, documentation fixes, dependency bumps that don't change behavior, obvious bug fixes where the bug and fix are self-evident.
Tracked in GitHub issues; this list is just the high-priority ones at project genesis:
- Verify nftables map atomic replace actually works the way we want
on kernel 6.x —
internal/nat/nat_integration_test.go(build tagintegration) verifiesSetup,SetMappopulate/replace/clear, and the F-19 write-side atomicity (every post-write dump matches the written state across ~50 flips/s). Driven bytest/integration/ run.ps1(Windows) orrun.sh(Linux/macOS) — privileged Docker container with--cap-add=NET_ADMIN. Concurrent userspace dumps can occasionally observe mixed snapshots; that's a kernel multi-message dump quirk, not a dataplane issue (see test doc-comment). - Phase-1 integration harness in
test/e2e/covering boot, nftables setup, discovery, DNAT-map population across all four DHCP scenarios (v4-only, v6-only, both, none). Fully Docker-native:test/e2e/run.ps1(Windows) ortest/e2e/up.sh(Linux/macOS) spawn a runner container that drivesdocker compose. 26/28 assertions green; the 2 fails are a Docker-side macvlan-on-bridge broadcast quirk, not anchord — re-verify v4 lease path on a real Linux host. - Phase-2 e2e: real listener inside the service-anchor namespace
- probe container on the lan bridge, S-2/S-3/S-6 assertions in
run.sh. Now at 70/70 green across 5 scenarios (v4-only, v6-only, both, none, dhcpv6-stateful) on Docker Desktop with the opt-inE2E_BRIDGE_FLOOD_FIX=1(setsbridge-nf-call-iptables=0to undo Docker's default iptables-FORWARD drop of bridge broadcasts). Real Linux hosts don't need the flag (see next item).
- probe container on the lan bridge, S-2/S-3/S-6 assertions in
- Implement service-anchor mode (SPEC §2.6, ARCHITECTURE role 2):
cmd/anchorddispatches onANCHORD_MODE,internal/serviceanchorpackage, F-24..F-29 contract honoured, e2e compose's smtp-anchor uses it. Done. - Code-hash test-report system + release gate:
scripts/{code-hash,update-test-report,verify-test-report}.{sh,ps1},.github/workflows/{ci,release-gate}.yml. README's auto-generated TEST-REPORT block is the release-readiness signal; release gate blocks tags that aren't on main or whose recorded hash is stale. - Real-host validation by way of production deployment.
v1.0.x..v1.2.x has been running on a bare-metal TrueNAS SCALE
host (kernel 6.12.x with physical VLAN sub-interfaces) against
real workloads since 2026-05-20 — Mailcow IMAP IDLE, Nextcloud
Talk WebSocket signaling (including the v1.2.1 hairpin fix),
RTSP camera streams, OIDC + LDAP binds, the lot. Issue #11
(DNAT hairpin) was both discovered and end-to-end-verified on
this host. The
E2E_BRIDGE_FLOOD_FIX=0e2e run is still nice to have for the synthetic harness, but production has been the stronger signal in practice. - Prometheus metrics surface decided + implemented (SPEC §2.7,
F-30..F-32, N-5). 12 metrics across both modes, bounded label
cardinality, custom collector for
dhcp_lease_remaining_secondsso the gauge decays at scrape time. Listener is loopback-only by default (ANCHORD_METRICS_ADDR=127.0.0.1:9090) so the LAN-facing macvlan never sees it. - Health endpoint shape decided + implemented (SPEC §2.8,
F-33..F-36).
/healthzis pure liveness (always 200);/readyzis mode-specific — network-anchor needs nft tables installed AND first reconcile complete; service-anchor needs at least one default route installed. DHCP lease state is not a readiness gate (DNAT path is iface-bound; thenone-DHCP scenario must reach ready). Both endpoints share the metrics listener. - DHCPv6 feature parity.
dhcp.Supervisorruns pure-Go v4 + v6 client goroutines in parallel under a shared child context (seerunClients/runFamily). Hostname/FQDN options are passed viadhcpv4.OptHostNameanddhcpv6.WithFQDN(0, hostname)(RFC 4704 client FQDN, flags=0 → "let server decide" on DNS updates). Both goroutines are cancelled together when the link watcher fires, so recovery from parent flap covers v6 too. e2e scenariodhcpv6-statefulhas dnsmasq announce stateful DHCPv6 instead of SLAAC and asserts anchord-ext gets its v6 address via the DHCPv6 path. v6 SLAAC scenarios still work because the v6 goroutine silently retries SOLICIT on networks without a DHCPv6 server, while the kernel does SLAAC from the RA in parallel. - Behavior when the VLAN parent interface goes down mid-run.
dhcp.Supervisor.Runcalls an idempotentensureLinkat the top of every iteration and runs a 2 s link-state watcher alongside the DHCP-client goroutines (watchLinkUsable). When the macvlan child disappears or is brought down — parent flap, externalip link del, etc. — the watcher cancels the child context, the goroutines exit (sending DHCPRELEASE for v4 on the way out), the loop wraps around andensureLinkrecreates the child. Verified by smoke-testingip link del anchord-extwhile the stack was up: link comes back at a new ifindex with the same IP within ~12 s. - Replace
dhclientsubprocess with pure-Go DHCP client. v0.1 shelled out to ISCdhclient; that has been migrated togithub.com/insomniacslk/dhcp(thedhcpv4/nclient4anddhcpv6/nclient6packages). Image no longer needs thedhclientapk. Renewal/release/IP-application now happen inline via netlink. Pure-Go DHCP simplifies the supervisor (no subprocess management, no tempdhclient.conffiles) and removes ISC-DHCP's EOL concern.
The README and SPEC are deliberately terse. Don't pad them with rhetorical framing or "important note" boxes. The reader is technical and doesn't need to be sold on the project — they need to ship something that works.
Funny is fine in commit messages and inline comments. Not in the SPEC.