Written with Claude.

Three Claude sessions coordinating across repo boundaries on a shared infrastructure cutover

The governor issue closed mid-session

At 16:52 UTC on May 22, meap2-it issue #1352 (“No Syncthing reads at runtime on dev-pi”) closed. That was the cascade governor: the issue that couldn’t close until every leaf under it had closed. Three concurrent Claude sessions across three repos for one focused day got it there. One session couldn’t have done this work at any speed. The cascade structure (a named invariant at the top, leaves underneath, each leaf owned by whichever repo holds the bytes) is what made parallel finishing possible. Some cross-cutting architectural debt isn’t single-session work; the cascade is the shape that admits parallel work closing it at all.

The MEAP system runs on two computers: a workstation where I develop, and dev-pi which is the production server at meap.chughes.co that field devices talk to. For months the two were kept in sync by a Frankenstein stack of Syncthing plus git push hooks plus a custom workstation-to-dev-pi sync script. That stack had a dozen subtle traps where state would silently disagree between the two machines. Today’s session deleted the last of that sync glue, made dev-pi authoritative for everything that field devices write, and split the shared MEAP login secret into one copy on each machine that the operator now rotates by hand. Cleaner, fewer traps, easier to reason about.

The May 20 post laid out the substrate decision: events.jsonl on dev-pi as a single source of truth, append-only via chattr +a, gateway writes from the field app. The argument was architectural. What the May 20 post couldn’t show was what it actually costs to finish a cross-repo architectural decision when you’re one person running multiple AI sessions across repositories that don’t share a context window.

That’s what today was.

No single session had the full picture

MEAP’s production infrastructure spans three repos with overlapping ownership:

  • meap2-it: the PCA command suite, deploy scripts, specs, and the tooling that coordinates workstation-to-dev-pi deploys
  • ChuggiesMart: the workspace-toolkit plugin, slash commands, agents, and the workspace CLAUDE.md that all sessions load on start (the rules shared across every repo)
  • chungus-net: the infrastructure Pi config, DNS, Caddy proxy, and the dev-pi side of everything

No session started in meap2-it can touch ChuggiesMart’s bindings. No session started in chungus-net can edit meap2-it’s deploy scripts. Claude Code’s context is the repo it opened in. A cross-repo change that looks like “one logical operation” becomes physically split the moment it crosses a repo boundary, and the correct owner has to make the edit.

This wasn’t a coordination problem I invented. The meap-sync-pull retirement was the clearest example. Retiring the mechanism required deleting workstation scripts in meap2-it, editing the /a-deploy-client command, and running rm ~/bin/meap-sync-pull on dev-pi, which sits under chungus-net’s operational domain. One retirement, two owners. The bytes-ownership rule (each session edits only what its repo owns) exists because the alternative is two sessions overwriting the same file.

The 932de769 commit message in meap2-it names the split directly:

Coordinated split with chungus-net session S315 per route-by-ownership
rule:
  meap2-it: this commit (workstation scripts + /a-deploy-client edit)
  chungus-net: parallel `rm ~/bin/meap-sync-pull` on dev-pi

That single commit message is a log of the protocol working. Two sessions, one logical operation, zero file collisions. What made the retirement necessary (and what made it such a clean two-owner split) goes back further than this session.

The stack drifted, it wasn’t designed

Nobody sat down and architected a Frankenstein sync setup. It accreted over five renames spanning six months.

November 2025: a Python + Flask server on dev-pi, workdir at ~/meap-field-server/, WORDBANK_PATH pointing at the Syncthing-mirrored repo. January 2026: a SvelteKit proto8 rewrite landed with an explicit MEAP_ROOT env var in the new systemd unit, still pointing at the Syncthing tree because that was the only copy of the repo on dev-pi. In January a reference-directory flatten renamed reference/apps/proto8/ to apps/field-interface/ in five commits over ten minutes; the dormant proto8 unit never got updated, but the new unit did. March 2026: a Bun runtime switch (95797ac5), clean ADR with measured startup and memory numbers, unit carried MEAP_ROOT forward explicitly. April 11: field-interfacemeap-web rename (b7280f9a, v6.34.0). New unit file, fresh deploy. MEAP_ROOT silently dropped.

The code had always carried a fallback: process.env.MEAP_ROOT || join(WORDBANK_PATH, '../..'). With the env var gone, the fallback resolved WORDBANK_PATH’s parent’s parent, which pointed at the Syncthing-mirrored source tree on dev-pi. That tree had been working fine. Then the syncthing@chris.service died. For a week, meap.chughes.co/changelog showed v6.72.0 while v6.76.0 was actually running. That’s the symptom that opened #1352 and triggered the cascade.

The bug class is: drop an env var from systemd, leave the code fallback in place, and the fallback silently becomes the production path. The retrospective that session S892 wrote named it lesson six. The night before this session, the ChuggiesMart session generalized it into a workspace rule. That’s what made the closed-loop catch possible.

Here is what the runtime topology looked like before this session. Four red-bordered nodes are the coupling points that kept the two hosts entangled at runtime:

graph LR
    subgraph WS["workstation 192.168.3.11 (BEFORE)"]
        Master["meap2-it master tree<br/>(under ~/2_project-files/)"]
        SharedSec["~/2_project-files/_shared/<br/>secrets/meap.env<br/>(read by production)"]
        WSClients["m2-clients/PROJECT/<br/>(workstation working copy,<br/>kept current via<br/>meap-sync-pull + git)"]
        PrePush["git pre-push hook<br/>(meap-sync-pull)"]
    end

    subgraph DP["dev-pi 192.168.3.4 (BEFORE)"]
        Syncthing["~/2_project-files/<br/>(Syncthing receive,<br/>read at runtime)"]
        MeapTools["meap-tools.service<br/>(triggered by pre-push,<br/>git commit+push<br/>dev-pi m2-clients)"]
        MeapSvc["meap-web.service :5255"]
        MClients["m2-clients/PROJECT/<br/>(authoritative writer,<br/>ADR-078/087)"]
    end

    GH["github.com/<br/>chuggies510/PROJECT"]

    Master <-->|"Syncthing<br/>(code, configs)"| Syncthing
    SharedSec <-->|"Syncthing<br/>(secrets)"| Syncthing
    PrePush -.->|"ssh trigger"| MeapTools
    MeapTools -->|"git push"| GH
    GH -->|"git pull (workstation)"| WSClients
    MClients -.->|"git commit+push<br/>via meap-tools"| GH
    MeapSvc -.->|"reads secrets via<br/>Syncthing-mounted path"| Syncthing
    MeapSvc --> MClients

    classDef coupling stroke:#d33,stroke-width:2px
    Syncthing:::coupling
    SharedSec:::coupling
    MeapTools:::coupling
    PrePush:::coupling

Two coupling points retired:

  1. Production secrets on a Syncthing-mirrored path. meap-web.service read ~/2_project-files/_shared/secrets/meap.env at boot, a file the workstation could rewrite. Now: /etc/meap/secrets.env on dev-pi only, mode 600, kept in lockstep manually at rotation.
  2. meap-sync-pull, a pre-push hook + ssh-triggered systemd unit. Workstation git push fired the hook, ssh’d into dev-pi, ran meap-tools.service, which committed and pushed m2-clients/ so the workstation could git-pull it back. Now: the workstation has no maintained m2-clients/PROJECT/ copy. All reads go through HTTPS to meap.chughes.co.

What the coordination protocol actually costs

The mechanics of cross-session IPC in this stack are genuinely awkward. Sessions don’t share a message bus. The transport is tmux send-keys to push a message to a peer session’s input, and the workspace CLAUDE.md has a gotcha about it: “Claude Code’s TUI buffers input during active responses; the first keystroke often lands as a newline/commit-intent rather than submit.” You send the message, then send a second Enter. This is the double-Enter rule, and it burned someone before it got written down.

Each relay message goes as a Minto-shaped report (situation, complication, resolution: one paragraph max). Per-relay summaries cost tokens and attention, but they buy a shared state snapshot. Three sessions, three independent JSONL context files with no consensus log. The only way session S893 could verify what S315 had done was to read S315’s memory-bank handoff after the fact, or wait for an explicit relay ping.

We ran three concurrent /code-review xhigh passes (nine agents total across the three sessions) and closed five days of cascade issues in a single coord cycle. The 8-issue meap2-it cascade (issues #1244 through #1352) had been building since May 13; the governing issue finally closed today because all three sessions ran their teardown work against a shared timeline. The cost is real but narrow: IPC overhead and the cognitive overhead of tracking which session owns which surface.

The closed-loop catch

The night before this session (May 21, 22:04), a ChuggiesMart session took that lesson from the S892 retrospective and wrote it into the workspace CLAUDE.md as a general rule: “Code fallback for dropped env var silently routes to stale state.” General enough to catch the same pattern anywhere in the stack.

This morning, about 12.5 hours later, the meap2-it session ran a /code-review xhigh pass over its own S893 commits. One of the reuse agents fired the new rule against tools/meap-login.ts, which had just had the Syncthing mechanism removed from beneath it. The docstring and the 401 error message still read (Syncthing) as if that mechanism still existed. The tool would have shipped with actively misleading error messages for the next rotation cycle.

The fix landed in 26d6cbc1:

- * (maxAge:8h is a browser-cookie attribute only). The local secrets file
- * (_shared/secrets/meap.env) is the same Syncthing-synced file that systemd
- * EnvironmentFile-loads into meap-web.service, so a locally-minted token
- * equals what the server computes.
+ * (maxAge:8h is a browser-cookie attribute only). The workstation reads
+ * MEAP_AUTH_SECRET from `~/2_project-files/_shared/secrets/meap.env`; dev-pi's
+ * meap-web.service reads it from `/etc/meap/secrets.env` (per-machine, not
+ * Syncthing-synced post-#1352 step 6). The two values must be kept in lockstep
+ * manually on secret rotation, or this tool mints tokens the server rejects.

-        `has drifted from the deployed secret (Syncthing). No token written.`,
+        `has drifted from dev-pi /etc/meap/secrets.env. No token written.`,

The rule that the ChuggiesMart session wrote at 22:04 the evening before caught the bug that the meap2-it session’s own teardown introduced at 10:27 the following morning. Neither session wrote the rule for this specific case. Neither session would have caught the Syncthing docstring ghost without a review agent that happened to load the fresh rule from the workspace. The catch was a side effect of the coordination protocol working as designed: when a rule gets generalized and written to the shared surface, every subsequent session that loads context gets it.

I hadn’t thought about it as a closed loop when we set it up. It turned out to be one.

What the infra side looked like

The chungus-net session (S315) was narrower than the other two: orphan-ref scrubs in memory-bank files after the ChuggiesMart-specs.service retirement, a stale Homer dashboard tile dropped, a DNS config tracked in git that should have been there for months.

The DNS work shipped with this header comment, which I’m keeping verbatim because it’s honest about something I’d normally want to paper over:

# Pi-hole v6 / FTL 6.6.x returns the local A answer to dig and to plain libc
# resolvers, but its AAAA-query path still forwards upstream. Clients running
# systemd-resolved follow the upstream CNAME chain (chughes-home.duckdns.org,
# the public WAN IP) and connect via hairpin NAT instead of the LAN path.
#
# local= should prevent forwarding entirely for the named subdomain;
# Observed result: A path resolves to 192.168.3.3 correctly, AAAA leak was
# NOT eliminated. Partial fix only.

Two sessions before this one inherited “Caddy down” as their priority because meap.chughes.co curl probes were timing out; the actual cause was AAAA queries following a CNAME chain to the public WAN IP and hairpinning back through a router that doesn’t NAT it. The A path had been fine the whole time. Partial fix on the dnsmasq side, honest comment in the file, issue #175 still open. Better than the next session inheriting “Caddy is down” as its mystery starting condition again.

What’s still open

The cutover is about 90% there, not done.

The two open structural items:

meap2-it issue #1349 (“Workstation m2-clients/ still load-bearing for new-project bootstrap”) is a design fork, not cleanup. The /a-deploy-client command’s Phase 1 still hardcodes m2-clients/0_proposals/ on the workstation. That assumption is baked into the new-project workflow. Removing it requires either redesigning the bootstrap step or accepting a permanent exception to the SSOT model for proposal-phase data. That’s a real architectural decision, not a stray reference to delete.

chungus-net issue #136 (“re-point 12 dev-pi services”) is the remaining operational debt. Twelve services on dev-pi still reference the Syncthing-mirrored paths instead of their dedicated deploy locations. Each service needs a config update and a reload. It’s not complex, but it’s twelve individual service changes with reload verification, and I haven’t had a session with the right focus to work through them without risking a service interruption chain.

The cascade governor (#1352) is closed because the meap-web service itself no longer reads from the Syncthing surface at runtime. The twelve services are older dependencies that predate the cutover work; they’re on the Syncthing surface because that’s where their configs always lived, not because they were doing event-sourced reads. Different problem, different session.

Here is the runtime topology after the cascade closed. The dashed retired node is meap-sync-pull; the dotted 0_proposals/ arrow is #1349, the one remaining workstation-to-dev-pi data path:

graph LR
    FieldDev["Field device<br/>(phone/tablet)"]
    Reviewer["Reviewer<br/>(workstation browser<br/>or iPad)"]

    subgraph WS["workstation 192.168.3.11"]
        Master["meap2-it master tree<br/>(this repo)"]
        Proposals["m2-clients/0_proposals/<br/>(workstation-only,<br/>#1349 open)"]
        WSSecrets["~/2_project-files/_shared/<br/>secrets/meap.env"]
        DevServer["bun run dev<br/>(local-only, :5173)"]
    end

    subgraph DP["dev-pi 192.168.3.4"]
        MeapSvc["meap-web.service<br/>:5255 (SvelteKit, SSR off)"]
        Gateway2["writeBuilding.ts<br/>(in-process)"]
        Events2["analysis/{building}/<br/>events.jsonl (chattr +a)"]
        View2["analysis/{building}/<br/>building.json (0444)"]
        MClients["m2-clients/PROJECT/<br/>(authoritative,<br/>ADR-078/087)"]
        DPSecrets["/etc/meap/secrets.env<br/>(600 chris:dialout)"]
        Symlinks[".claude/{agents,commands,skills}<br/>symlinks → meap2-it/deploy/<br/>(ADR-047)"]
    end

    Caddy["infra-pi 192.168.3.3<br/>(Caddy TLS)"]
    DNS["meap.chughes.co"]

    FieldDev -->|HTTPS| DNS
    Reviewer -->|HTTPS| DNS
    DNS --> Caddy
    Caddy -->|:5255| MeapSvc

    Reviewer -.->|"ssh -L 5255<br/>(local dev only)"| MeapSvc
    DevServer -.->|"reads (dev only)"| MClients

    MeapSvc --> Gateway2
    Gateway2 --> Events2
    Events2 -->|projection| View2
    MeapSvc -->|reads| View2
    MeapSvc --> MClients
    MeapSvc -.->|reads| DPSecrets

    Master -->|"/a-deploy-server<br/>rsync build/"| MeapSvc
    Master -->|"/a-deploy-client<br/>ln -sf"| Symlinks
    Master -->|"/a-deploy-client<br/>ssh git clone"| MClients
    Proposals -.->|"/a-deploy-client Phase 1<br/>(workstation-only path,<br/>#1349 open)"| MClients

    Master -.->|reads| WSSecrets

    classDef retired stroke:#999,stroke-dasharray:5 5,color:#999
    Retired["meap-sync-pull<br/>(RETIRED 2026-05-22, #1352)"]:::retired
    Retired -.-x MeapSvc

Solid arrows are runtime traffic. Dashed arrows are deploy-time only or dev-only. The dev-pi side carries the gateway, the event log, and the derived view; the workstation side is a development environment with one residual data path through 0_proposals/.

Why the cascade shape mattered

The parallel-equals-faster framing misses what was true here. Three sessions ran in parallel because no single session could reach the bytes the work needed to touch. The cascade structure made the bytes physically separable across owners.

The meap-sync-pull retirement is the cleanest example. Closing it required deleting workstation scripts in meap2-it, editing /a-deploy-client, and running rm ~/bin/meap-sync-pull on dev-pi under chungus-net’s operational domain. One logical operation, two repos, two owners. With one session, you cannot edit the bytes the other repo owns; you can only ask, and asking is sequential. With the cascade governor naming the invariant and each leaf naming a specific obstacle, the leaves had clear owners by construction. Two sessions ran their halves of the meap-sync-pull retirement against the same shared timeline. The commit message in 932de769 is the receipt: one logical operation, zero file collisions.

The discipline this points at, separately from anything Claude-specific:

When you’re holding cross-cutting architectural debt that touches multiple ownership boundaries, file a governor issue that names the invariant (“nothing in this surface depends on that mechanism”). File leaves under it that name specific obstacles to the invariant. Close leaves until the invariant holds without exceptions, then close the governor by proof rather than by checklist. The cascade shape is what makes the work bounded and the ownership clear. Parallel finishing is the secondary effect of having that shape in place.

Some cleanup is a single context’s work. Do it that way; the ceremony is overhead you don’t need. Some cleanup is governor-shaped. Recognize when you’re holding one of those, and stop trying to compress it into a single session. The cascade structure is what lets that work close.

The governor closed at 16:52.