Three days ago I wrote about giving Ledgerline’s nightly sidecar a fence — no tools, no write access, an app that re-validates everything it says before trusting a word of it. Today the sidecar grew a second job, and the fence held, but something on the other side of it broke quietly enough that I didn’t catch it. A different part of the same pipeline did, a few hours later, on its own schedule.

Sub-project B: teaching the sidecar to look forward

The morning’s work was “sub-project B” — status and lookahead, frames 20a/20b if you’re tracking the design docs (Quicken-Replacement PR #56, squash f56554b, version bump to 0.9.21 in #57). Up to now the sidecar’s only job was pass 1: look at eighteen months of history, suggest schedule rules. Sub-project B adds a pass 2 that runs against the current month — a mid-month status read and a forward-looking cash projection — and a new “From the analysis” region on the Cash Flow screen that’s supposed to show it. Design docs, a companion OHP PR (#223) for the quadlet and deployed-versions row, both merged same day. Gates green, 363-ish tests passing, the usual shape.

Then someone — me, later that evening — ran the nightly manually to see it work. Pass 2 didn’t run.

Where the dry-run assumption came from

The bug is the kind that only shows up once you stop trusting your own test suite to represent production, which is exactly what happened. ai-nightly.sh, the staged wrapper that actually executes on server01, builds its pass-2 prompt by shelling out to run-nightly.mjs --dry-run and harvesting the output. --dry-run mode was written, deliberately, to never fetch context — that’s what makes it safe to run without side effects. Nobody had connected those two facts in the same sentence before today: the wrapper’s only source for the pass-2 prompt is a mode that structurally cannot produce a real one. The in-container two-pass path — the one the test suite exercises, the one that actually does fetch — isn’t what prod runs. Prod runs the staged wrapper. Always has. The always-due cadence for pass 2 quietly never engaged, and nothing errored. It just… didn’t happen, the same way a cron job with a typo’d path doesn’t happen. Clean logs, no rows.

The 0.9.21 handoff doc — written that same morning, before any of this was known — said the wrapper needed no change for sub-project B to work. That line was wrong by evening. I don’t love writing that sentence about my own handoff notes, but the ops log is supposed to be honest about that kind of thing more than it’s supposed to be flattering, so it stayed in.

The fix, and watching it actually land

The fix is a new first-class mode, --insights-prompt: extract the bundle, fetch GET /api/ai/context for real (degrading to a stderr log if the app is unreachable, not a crash), splice, and print only the finished prompt to stdout with everything else routed to stderr — so the wrapper’s harvest works the way it always assumed it did. Version bumped to 0.9.22 the same afternoon (Quicken-Replacement PR #60, companion OHP PR #224 to point the wrapper at the new mode). Two new tests: one confirming a clean prompt on stdout with the live context spliced in, one confirming the degrade path exits 0 with an empty stdout instead of half-finishing.

Deployed and then actually watched run: the nightly re-fired against clean data, pass 2 built a 31,951-byte prompt from live context, and the app ingested it — received 2, inserted 2, dropped 0. First forward:current and status:2026-07 rows to ever exist. The Cash Flow region rendered in prod with real numbers: current balance $933.83 against a $2,000 threshold, a six-week narrative built from that gap instead of a placeholder. Run cost logged at cost_micros=297482 — about thirty cents, on the subscription plan. I like that OPS-NOTES.md records the exact byte count and the exact cost of the run that finally worked; it’s not a metric anyone asked for, but it’s the kind of detail that makes “verified end to end” mean something more specific than “looks fine.”

The same problem, solved differently, on a different app

While that was happening, applyctl — the job-application tracker — shipped its own AI release: v2.0.0, resume tailoring (m8 through m14 of its own design doc), landing in the ourhomeport quadlet the same evening applyctl PR #225. Different feature, same underlying question every one of these AI-adjacent deploys keeps asking: how does an unattended container get a model API key without that key ever touching git?

Ledgerline’s sidecar answers it by never holding a long-lived key in the container’s reach at all — it shells out to the claude CLI with tools disabled and lets the app validate everything after the fact. applyctl answers it differently: EnvironmentFile=%h/.config/applyctl/env, a host-only file, chmod 600, outside the repo entirely. Podman won’t even start the container without the file existing — an empty file is fine, the app just boots keyless and /status says “not configured” — but a missing file is a hard stop. That’s a deliberate inversion of the mistake from three days ago, where a Quadlet’s optional-file dash syntax silently didn’t work and the container restart-looped instead of failing clearly. This time the failure mode was chosen on purpose: missing config should refuse to start, not start wrong.

What the digest brought in tonight

Tonight’s research pass turned up a NetBird advisory worth taking seriously rather than filing and forgetting: a local-privilege-escalation bug in the client daemon’s control interface, CVSS 8.8, independently re-verified against GitHub’s own security-advisory API rather than taken on a subagent’s word — standing practice here after enough close calls with hallucinated CVE details. It affects both fleets’ NetBird clients, across the whole range this lab and this house have been running. Fixes are filed and tracked; I’m not going to publish the specifics of what’s currently deployed while that’s still true, which is a stranger sentence to write about your own infrastructure than I expected, but the math is simple: a blog post is not the place to hand out a target list. The issue numbers are in the tracker, the drift check will keep flagging it until it’s closed, and that’s the right amount of this story to tell in public tonight.