Adron's Composite Code
Technological and coding misadventures!
https://compositecode.blog/
2026-11-13T12:30:20-08:00
Adron Hall
GitHub PRs are slow. Keep shipping anyway.
https://compositecode.blog/2026/11/05/when-github-is-the-slowest-part-of-delivery/
2026-11-05T04:00:00-08:00
2026-11-05T04:00:00-08:00
The issue is ready, the code is ready, and then the PR page spins. A check stays queued. A webhook arrives late. The review that should have taken ten minutes turns into an afternoon of refreshing GitHub and wondering which part of the pipeline is actually broken.
<p>The issue is ready, the code is ready, and then the PR page spins. A check stays queued. A webhook arrives late. The review that should have taken ten minutes turns into an afternoon of refreshing GitHub and wondering which part of the pipeline is actually broken.</p>
<p>That is not hypothetical in October 2026. GitHub’s <a href="https://github.blog/news-insights/company-news/the-august-17-outage-and-the-work-ahead/">August 17 incident report</a> says a 7 hour, 47 minute outage disrupted the site, APIs, Actions, pull requests, and issues. Its <a href="https://github.blog/news-insights/company-news/github-availability-report-september-2026/">September availability report</a> describes database pressure and work to contain failures in pull requests. The <a href="https://www.githubstatus.com/history">status history</a> also records October delays across PRs, Issues, Actions, and webhooks. GitHub is doing capacity work. A development team still needs a plan for the hours when its work is waiting on that capacity.</p>
<h2 id="find-the-actual-wait">Find the actual wait</h2>
<p>“GitHub is slow” describes the feeling, not the bottleneck. Track issue ready to first code change, code complete to first review, review complete to checks, checks complete to merge, and merge to deploy. Look at the median and the ugly tail. A slow PR page is one problem. A two day review queue is another. A CI job that queues for an hour and runs for three minutes has a different remedy from a test suite that takes an hour on a ready runner.</p>
<p>This measurement matters because some delays are in our own process. If a task cannot start until somebody writes an elaborate issue, or a trivial change waits behind a weekly approval meeting, GitHub uptime will not fix it. Write the acceptance criteria and the risk in a short issue or local task note, then let implementation start. The artifact should help the next person understand the work, not become a ceremony the work must survive.</p>
<p><img src="/assets/when-github-is-the-slowest-part-of-delivery/delivery-timeline.svg" alt="The delivery timeline separates local work, GitHub dependent review and checks, and deployment" /></p>
<h2 id="move-the-fast-feedback-close-to-the-code">Move the fast feedback close to the code</h2>
<p>Make the build, focused tests, formatting, and relevant static checks runnable with one documented local command. Run them before opening the PR. A remote check is still useful as the shared attestation, but it should confirm a result developers can reproduce, not be the first place anyone discovers a compiler error. Cache dependencies carefully, keep the quick checks quick, and reserve expensive matrices for changes that need them.</p>
<p>Review can begin from a branch or a patch while the PR UI is unhealthy. A reviewer can read <code class="language-plaintext highlighter-rouge">git diff</code>, run the relevant tests, and write findings in a shared local note. When GitHub recovers, put that discussion on the PR so the decision remains visible with the change. Do not mistake a green local run for the required check on a protected branch. <a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches">Branch protection</a> is a team contract, and an outage is a poor reason to silently discard it.</p>
<h2 id="keep-a-second-path-for-coordination">Keep a second path for coordination</h2>
<p>Issues are a convenient source of truth until the issue service is unavailable. Keep enough task context in the repository or another team owned system to continue: a task ID, expected behavior, owner, and decision log. It can be a small Markdown file. When GitHub returns, reconcile it to the canonical issue. Avoid two competing permanent backlogs.</p>
<p>Git itself is distributed. If GitHub’s web services are degraded but Git operations still work, teams can continue committing and exchanging branches through the remote. If Git operations are unavailable too, local commits keep the work safe. For a prepared contingency between trusted machines, <a href="https://git-scm.com/docs/git-bundle">Git bundles</a> can carry repository history without a live server. A second forge or internal mirror can provide a more complete continuity path, but only if the team has already rehearsed access, review, and reconciliation. Setting one up during an incident is not a shortcut.</p>
<p>Self hosted Actions runners solve a different problem: where jobs execute. <a href="https://docs.github.com/en/actions/reference/runners/self-hosted-runners">They still connect to GitHub for job assignments</a>, so they do not remove the Actions control plane dependency. A locally runnable test script does. A separate CI system can, if its triggers and repository access are independent too.</p>
<h2 id="reduce-the-amount-of-github-work-per-change">Reduce the amount of GitHub work per change</h2>
<p>A giant PR asks the platform and the reviewer to do too much at once. Keep changes small enough to inspect and merge. Separate mechanical cleanup from behavior changes. Use feature flags or compatible incremental changes so one PR need not contain the entire release. Avoid automations that trigger redundant workflows on every push, comment, label, and issue edit. They add queue pressure and make it harder to tell which signal matters.</p>
<p>This is also where agent output can hurt. An agent that opens a PR for every tiny correction can swamp reviewers and checks. One that generates a thousand file change is worse. The useful middle is a coherent slice with a clear behavior change and a reproducible local result.</p>
<h2 id="decide-what-happens-during-an-outage-before-one-happens">Decide what happens during an outage before one happens</h2>
<p>For ordinary work, keep committing, testing, and reviewing locally, then publish when service recovers. For urgent production work, use the team’s documented emergency path, including independent review, recorded test evidence, and a later audit trail. If branch rules require a specific bypass, make that an explicit incident decision by the people authorized to make it. Do not give agents broad bypass permission so they can “keep shipping.”</p>
<p>GitHub should remain a useful place to coordinate and verify work. It does not need to be the only place work can happen. The practical goal is a pipeline that can keep making safe progress when a PR page, webhook, or runner queue has a bad day.</p>
AI code quality: Ship fast without the fires
https://compositecode.blog/2026/11/04/ai-code-needs-a-faster-quality-loop/
2026-11-04T04:00:00-08:00
2026-11-04T04:00:00-08:00
The October 2025 DynamoDB disruption spread through AWS after a race condition in automated DNS management erased the service’s regional endpoint. Its published cause was an automation defect, not AI-written code. That distinction matters. If we blame every outage on an LLM, we miss the specific mechanisms that made the outage possible.
<p>The <a href="https://aws.amazon.com/message/101925/">October 2025 DynamoDB disruption</a> spread through AWS after a race condition in automated DNS management erased the service’s regional endpoint. Its published cause was an automation defect, not AI-written code. That distinction matters. If we blame every outage on an LLM, we miss the specific mechanisms that made the outage possible.</p>
<p>The AI-related cases are narrower and messier. Reporting connected an AI coding tool to an <a href="https://www.aboutamazon.com/news/aws/aws-service-outage-ai-bot-kiro">AWS Cost Explorer interruption</a>, while Amazon attributes that event to misconfigured access controls and disputes the reported cause. In separate <a href="https://www.aboutamazon.com/news/company-news/amazon-outage-ai-financial-times-correction">Amazon retail incidents</a>, the company says an engineer followed advice an AI tool inferred from an outdated internal wiki. These cases do not prove that faster AI development caused AWS’s broad outage. They do show how stale context and excess authority can give an ordinary mistake operational reach.</p>
<p>There is a cleaner example. In 2025, <a href="https://replit.com/blog/doubling-down-on-our-commitment-to-secure-vibe-coding">Replit acknowledged</a> that its agent deleted data from a user’s application database while development and production data were insufficiently separated. The data was restored, but the production application could have been broken during the recovery window. Replit’s response was to separate the databases by default and keep the agent away from production data during development. The system had given a development tool production reach.</p>
<p>Most failures look less dramatic. An agent adds a retry around a payment call without an idempotency key. It makes a slow query pass the unit suite by mocking the database. It replaces a boring, explicit validation rule with a generic helper that accepts an empty value. Each diff can look reasonable. Each can make the application worse. Generate enough of those diffs, and the review queue becomes an exercise in finding one dangerous sentence in a book that keeps rewriting itself.</p>
<h2 id="the-old-gate-is-getting-squeezed">The old gate is getting squeezed</h2>
<p>The traditional bargain was that a developer made a change, a reviewer read it, CI ran, and the team shipped. Those steps still matter. The trouble is the arrival rate. Agents can produce plausible changes faster than a team can understand them. A reviewer staring at a giant generated PR is likely to check syntax and style, then miss the changed behavior. A test suite assembled by the same agent can reinforce the same mistaken assumption.</p>
<p>The answer cannot be “add three more approvers.” That makes the queue longer without making anyone better informed. The real unit of review needs to get smaller, and the evidence attached to each change needs to get better. I argued for <a href="https://compositecode.blog/2026/04/15/hurting-or-helping-devs/">small, scoped changes</a> before the current volume of agent output made the problem obvious. A reviewable diff has one job, names its expected behavior, and says which failure modes it considered. A generated feature that rewrites twelve unrelated files needs to be split before it reaches a reviewer.</p>
<h2 id="put-checks-where-mistakes-enter">Put checks where mistakes enter</h2>
<p>Start before code generation. Give the agent the current source of truth: the domain terms, the relevant files, the interface it must preserve, and the operations it may perform. If the design is unclear, ask for a plan and a list of unknowns first. My <a href="https://compositecode.blog/2026/08/20/plan-then-build/">ask, plan, confirm workflow</a> is useful here because it makes a change in authority visible. A read only investigation should not quietly become a production edit.</p>
<p>Next, make every implementation change answer three concrete questions in its PR description:</p>
<ol>
<li>What behavior changed for a user or another system?</li>
<li>What happens when the dependency is slow, unavailable, or returns an unexpected result?</li>
<li>How will we know the change is wrong after deployment, and how will we back it out?</li>
</ol>
<p>Those questions are more valuable than a declaration that “all tests pass.” Tests need to exercise the behavior, including a failed request, a duplicate request, and a partially completed operation where those cases apply. A retry that works on the happy path can duplicate an order. A migration that passes locally can lock a table under production load. The test plan should follow the risk, not the quantity of generated code.</p>
<p>Then put hard boundaries around the places where a wrong answer is expensive. Development agents do not need production credentials. Migration agents do not need permission to destroy live data to draft a schema change. Deployments that alter durable state deserve a separate authorization step and a recovery rehearsal. The Replit incident makes that point more clearly than any policy memo.</p>
<h2 id="review-the-behavior-then-the-shape-of-the-code">Review the behavior, then the shape of the code</h2>
<p>“Clean code” is too easy to reduce to formatting. I want a reviewer to trace the new behavior through the caller, the domain rule, the persistence boundary, and the observable result. Does the code use the team’s vocabulary? Does it duplicate a rule already implemented elsewhere? Does it leave a clear place for the next change? Is the error path understandable to someone on call at 2 a.m.?</p>
<p>An independent review helps when it has a specific lens. Ask one reviewer to look for changed invariants and another to check operational failure modes. Give both the actual diff and current contract. Do not ask the authoring agent to certify its own work with a cheerful paragraph. In <a href="https://compositecode.blog/2026/02/12/additive-vs-mutative-vs-destructive-code-changes-and-why-ai-agents-love-the-wrong-one-at-213am/">additive, mutative, and destructive changes</a>, I used those categories to make the impact of a diff easier to see. They also help decide how deep the review and rollout need to be.</p>
<p>Finally, use production as a feedback loop without treating customers as the test suite. Release behind a flag when practical. Start with a small cohort. Watch error rate, latency, saturation, and business outcomes. Have a known rollback path. If the change modifies data, confirm that rollback restores behavior even when old code meets new data. An automatic rollback triggered by a reliable signal beats a heroic incident call after everyone has gone home.</p>
<h2 id="change-the-throughput-target">Change the throughput target</h2>
<p>The target is not generated lines per engineer or PRs merged per day. It is useful behavior delivered without borrowing trouble from next week. Track how often a change needs a follow-up fix, how long failures take to detect, and how easily the team can restore service. If those numbers get worse while code output rises, the pipeline is accelerating in the wrong direction.</p>
<p>AI can make the first draft cheap. Understanding the system, containing the blast radius, and learning from what shipped are still the work. A faster keyboard makes those jobs more urgent.</p>
Long-running coding agents: When to stop and split
https://compositecode.blog/2026/11/03/keeping-long-running-agents-on-track/
2026-11-03T04:00:00-08:00
2026-11-03T04:00:00-08:00
I have written about Herdr as a place to see all the agents and about why I give spaces, tabs, and panes specific jobs. A multiplexer helps me find the agent that is blocked on a question. It does not tell me whether the agent should still be working on that question at all.
<p>I have written about <a href="https://compositecode.blog/2026/10/09/herding-agents-how-i-actually-use-herdr-on-a-ridiculously-wide-screen/">Herdr as a place to see all the agents</a> and about why I give <a href="https://compositecode.blog/2026/10/09/spaces-tabs-panes-and-precise-words-why-i-break-agent-work-out-the-way-i-do/">spaces, tabs, and panes specific jobs</a>. A multiplexer helps me find the agent that is blocked on a question. It does not tell me whether the agent should still be working on that question at all.</p>
<p>That distinction matters when several agents are active. Six sessions can all report “working” while two implement the same feature, one waits on a test process that will never exit, and another repeatedly rewrites a file to satisfy an impossible prompt. The dashboard is truthful about process state. It is silent about whether the work is useful.</p>
<h2 id="give-each-run-a-finish-line">Give each run a finish line</h2>
<p>Before starting an agent, write down its owner, scope, expected output, and stop condition. “Improve the billing service” has no finish line. “Add an idempotency check to <code class="language-plaintext highlighter-rouge">POST /charges</code>, show the duplicate request test, and stop before changing the database schema” does. The run can finish, fail, or ask for a decision. Each state has something a human can inspect.</p>
<p>I also set a budget appropriate to the task: elapsed time, tool calls or tokens if the platform exposes them, files touched, and number of attempted approaches. These are tripwires, not productivity scores. An agent spending twenty minutes on a slow integration test may be doing exactly the right thing. An agent spending twenty minutes alternating between two incompatible implementations is not.</p>
<p>A useful checkpoint is a short record of what has changed, what has been verified, what remains, and what is blocked. The evidence should be commands and artifacts, not just the agent’s confidence. If a long run cannot say what changed since the previous checkpoint, I want to inspect it before buying another hour of execution.</p>
<h2 id="long-running-can-be-legitimate">Long running can be legitimate</h2>
<p>Some work has a real long pole. A full build, a large data migration rehearsal, a cross platform test matrix, or a carefully scoped investigation may need hours. The clue is visible progress against a fixed plan. The agent reaches milestones, the diff stays within scope, test results accumulate, and the remaining work gets smaller. The clock alone is a poor judge.</p>
<p>Other runs are long because the task contains too many different verbs. The agent is asked to research a domain, design an API, implement it, update dependencies, test it, deploy it, and review its own work. Each phase changes what “done” means. The conversation grows, old assumptions remain in context, and the agent starts using its own earlier guesses as facts. That is where I stop and split the work.</p>
<p>My practical test is to ask whether the current run can be reviewed without replaying the entire transcript. If the answer is no, ask for a checkpoint and create a new task with the verified facts. Split at a decision boundary: investigation into a brief, brief into a design, design into one implementation slice, implementation into an independent review. Do not split a single atomic edit into five agents just because parallelism is available. The handoff can cost more than the edit.</p>
<p><img src="/assets/keeping-long-running-agents-on-track/checkpoint-decisions.svg" alt="A checkpoint decision: continue a run with verified progress, split work with multiple decisions, or stop work without new evidence" /></p>
<h2 id="supervise-the-queue-not-every-keystroke">Supervise the queue, not every keystroke</h2>
<p>With several agents, I want one visible register of active work. A simple table is enough: task, owner, branch or environment, paths or interfaces owned, state, last checkpoint, and next decision. The multiplexer tells me where the process is. The register tells me what the process is allowed to change. When an agent goes blocked, I answer the decision once and record it where the next session will find it.</p>
<p>I keep a limit on simultaneous writers. More agents do not automatically mean more completed work. They create more integration edges and more reviewer load. Read only investigators and reviewers can often run alongside an implementer. Two agents changing the same contract, deployment configuration, or migration sequence need sequencing even if their files are separate. <a href="https://compositecode.blog/2026/08/19/worktrees/">Worktrees isolate files</a>; they do not isolate architecture, a shared database, or the main branch after merge.</p>
<p>The review agent gets a different job from the writer. It reads a committed diff and reports behavior risks with file and line evidence. It does not silently fix what it finds. A fix becomes another scoped implementation task. That keeps the person supervising the queue from confusing an agent’s reported success with independent verification.</p>
<h2 id="a-run-needs-an-end-even-when-the-project-does-not">A run needs an end, even when the project does not</h2>
<p>“Keep going until everything is perfect” is an expensive prompt and an impossible stop condition. I prefer a bounded assignment that can end with a useful handoff: what landed, what failed, what remains, and the exact next slice. A fresh session can consume that handoff plus the source files. It does not need the entire history of abandoned attempts.</p>
<p>There are exceptions. An agent supervising a known batch job may stay alive to watch it, provided it has a timeout, a clear alert condition, and no open ended permission to change the system when something looks strange. A long lived process should be easier to audit than a short one, because it has more opportunities to drift.</p>
<h2 id="a-punch-list-for-a-busy-team">A punch list for a busy team</h2>
<ol>
<li>Name one owner and one finish line for every agent run.</li>
<li>Record the paths, interfaces, and environments each writer may touch.</li>
<li>Limit concurrent writers to the number the team can integrate and review.</li>
<li>Set checkpoints and tripwires for time, tool use, diff size, and repeated failed approaches.</li>
<li>Split work at design, implementation, and review decisions. Preserve verified facts in a short handoff.</li>
<li>Give production resources separate permissions from development resources.</li>
<li>Run meaningful behavior and failure tests before merging, then review the combined result.</li>
<li>Ship with observability and a recovery path, especially for data and deployment changes.</li>
</ol>
<p>The aim is not to keep every agent busy. It is to keep the codebase coherent and the application boring to operate. A blocked agent is easy to spot with the right tool. A busy agent making the wrong change needs a clear assignment, evidence, and someone willing to stop the run.</p>
AI agent collisions: Worktrees aren't enough
https://compositecode.blog/2026/11/02/agent-collisions-beyond-worktrees/
2026-11-02T04:00:00-08:00
2026-11-02T04:00:00-08:00
Two developers editing the same service at once is older than Git. One changes an API, the other writes a caller against the old shape. One migration lands, the other assumes the previous schema. The collision may wait until integration, but nobody invented it with coding agents.
<p>Two developers editing the same service at once is older than Git. One changes an API, the other writes a caller against the old shape. One migration lands, the other assumes the previous schema. The collision may wait until integration, but nobody invented it with coding agents.</p>
<p>Agents compress the timeline. They can start the overlapping work within seconds, finish competing implementations before either human sees a diff, and spend tokens repeatedly “fixing” each other’s changes. A human collision can cost far more than the token bill, sometimes an order of magnitude more once meetings, rework, and a delayed release are counted. That is a scenario, not a universal ratio. The new problem is how quickly both kinds of cost can accumulate while everyone thinks parallel work is progress.</p>
<p>I have already written the case for <a href="https://compositecode.blog/2026/08/19/worktrees/">giving each agent its own worktree</a>. Set that option aside here. Even with separate directories, two agents can change the same behavior, rely on incompatible contracts, or point tests at the same database. We need ways to manage the work itself.</p>
<h2 id="assign-ownership-at-the-level-that-matters">Assign ownership at the level that matters</h2>
<p>File ownership is a useful start, but interfaces are the real collision surface. If one agent changes the response shape of <code class="language-plaintext highlighter-rouge">GET /orders</code> and another changes the frontend that consumes it, they are collaborating on one contract. Give one task ownership of that contract and sequence the dependent implementation after the new shape is agreed. If parallel work is valuable, freeze the interface first and let both agents build against a shared contract test.</p>
<p>The same rule applies to domain terms, schemas, feature flags, and deployment configuration. A small task register can say: agent A owns the API contract and tests; agent B owns the UI after the contract is approved; agent C performs a read only review. It need not be a complicated scheduler. Its purpose is to stop two plausible plans from quietly disagreeing.</p>
<p>Use short leases on hot areas. A lease says who is editing a shared contract, when they expect to hand it off, and where the result will appear. It is a social protocol with a visible record, not a file lock that magically prevents bad code. If an agent exceeds the lease, review progress and renew or split the task. Do not let an abandoned session hold the architecture hostage all day.</p>
<h2 id="choose-the-kind-of-parallelism-deliberately">Choose the kind of parallelism deliberately</h2>
<p>Some tasks are naturally parallel: read only investigation, documentation of settled behavior, tests for a frozen contract, or independent review. Others are sequential: a migration followed by code that assumes it, a public API change followed by client updates, or two fixes to the same state machine. Forcing the second group into parallel execution creates integration work disguised as speed.</p>
<p>When the work can be split, split by stable boundary. “Agent A handles orders, agent B handles billing” may work if the interface between them is already explicit. “Agent A edits the first half of the file, agent B edits the second half” probably will not. File lines are not architecture. A good boundary gives each agent a testable outcome and few shared assumptions.</p>
<p>Another option is a single writer with several readers. Ask separate agents to investigate edge cases, inspect the current behavior, or critique the plan. Feed those findings to the writer after a human checks them. That gets the benefit of parallel thought without parallel mutation. It is particularly useful in a sensitive area where three implementations would be hard to reconcile.</p>
<h2 id="isolate-the-shared-things-that-are-not-code">Isolate the shared things that are not code</h2>
<p>Separate branches do nothing for a shared development database, a common staging environment, one cloud account, or the same test tenant. Give agent runs separate databases or schemas where practical, unique test data, distinct ports, and credentials limited to their task. If isolation is not available, schedule the shared resource as a single writer and make its current owner visible.</p>
<p>Generated artifacts need the same care. Two agents can each pass tests locally, then regenerate the same client library from different versions of a schema. A dependency lockfile can change under both tasks. A shared cache can hide an integration failure. Decide which task owns generation, pin the input, and test the combined result after both changes are present.</p>
<h2 id="detect-conflict-before-merge-time">Detect conflict before merge time</h2>
<p>At task start, have each writer declare the files, interfaces, and resources it expects to change. Recheck that declaration when the diff expands. If an agent discovers it needs a boundary owned by another task, it stops and asks. This is a cheap rule, and it prevents a lot of expensive “helpful” edits.</p>
<p>At integration time, do more than resolve text conflicts. Run contract tests and the affected application path against the combined code. Look for two implementations of the same behavior, changed assumptions about defaults, and migrations whose order matters. A clean merge only proves Git could combine the files. It says nothing about whether the resulting application makes sense.</p>
<p>When a collision does occur, preserve both task intents before choosing a fix. Ask what behavior each agent was trying to protect, then select the contract the product actually needs. Blindly asking agents to reconcile each other’s diffs often creates a third implementation that nobody intended.</p>
<h2 id="keep-parallel-work-cheaper-than-serial-work">Keep parallel work cheaper than serial work</h2>
<p>A small number of clear assignments, visible ownership, bounded runs, and one integration pass will beat a large crowd of agents whose outputs need arbitration. Count rework and review time alongside token use. If adding another agent makes the queue of conflicting diffs longer, reduce concurrency or stabilize the interface first.</p>
<p>Collisions were always part of software development. Agents make them arrive while the coffee is still hot. The fix is to make the boundaries, contracts, and shared resources explicit before the work starts, then check the integrated behavior before calling it done.</p>
Spaces, Tabs, Panes, and Precise Words: Why I Break Agent Work Out the Way I Do
https://compositecode.blog/2026/10/09/spaces-tabs-panes-and-precise-words-why-i-break-agent-work-out-the-way-i-do/
2026-10-09T12:37:57-07:00
2026-10-09T12:38:45-07:00
In the previous post I toured Herdr and how I lay it out: one space per folder, one tab per kind of work, agents in tall columns, shells in short stacks, all of it spread across a 5120×1440 ultrawide. That’s the what. This one is the why, and then the part that actually decides whether …
<p>In <a href="/2026/10/09/herding-agents-how-i-actually-use-herdr-on-a-ridiculously-wide-screen/">the previous post</a> I toured <a href="https://herdr.dev/">Herdr</a> and how I lay it out: one space per folder, one tab per kind of work, agents in tall columns, shells in short stacks, all of it spread across a 5120×1440 ultrawide. That’s the <em>what</em>. This one is the <em>why</em>, and then the part that actually decides whether any of it pays off: how I talk to the agents once they’re in their panes.</p>
<p>Short version: the layout isn’t decoration. It’s <strong>context management for two kinds of brains</strong>, mine and the models’, and the prompts are the same idea at a smaller scale. Every tool in this stack, Claude Code, Codex, Cursor, and the human, gets worse the more unrelated junk is in its head. So I keep the junk out, structurally first and then verbally.</p>
<h2 id="part-1-why-i-break-it-out-like-this">Part 1: Why I break it out like this</h2>
<h3 id="spaces-are-folders-because-the-folder-is-the-agents-whole-world">Spaces are folders because the folder is the agent’s whole world</h3>
<p>An agent’s working directory isn’t a detail. It’s the edge of its universe. Whatever it can read, grep, build, and break starts at that folder. So when I make <strong>one space per folder</strong>, I’m not organizing windows. I’m drawing the same boundary the agent already lives inside, and making it visible to me.</p>
<p>That buys three things:</p>
<ol>
<li><strong>The sidebar is a readout of my working memory.</strong> Ten spaces means ten things in flight. If a folder isn’t in the sidebar, I’m not working on it. When I’m done, the space goes away. There’s no “misc” space and no “scratch” space, because “misc” is where context goes to die.</li>
<li><strong>Blast radius is legible.</strong> If something is going sideways in <code class="language-plaintext highlighter-rouge">ledger-service</code>, the dot next to <code class="language-plaintext highlighter-rouge">ledger-service</code> says so. Nothing in that space can be quietly editing <code class="language-plaintext highlighter-rouge">storefront-web</code>, because it doesn’t live there.</li>
<li><strong>It pairs with worktrees.</strong> When I need two implementation agents on the same repo at once, each gets its own worktree and its own branch, and in Herdr that’s just another folder, so another space (<code class="language-plaintext highlighter-rouge">herdr worktree create</code> does this directly). I made the full case in <a href="/2026/08/19/worktrees/">Giving Every Agent Its Own Branch: Git Worktrees for Parallel AI Work</a>: “Run two coding agents against a single working tree and they will fight.” Spaces-as-folders keeps the referee’s view honest.</li>
</ol>
<h3 id="tabs-are-modes-because-each-mode-is-a-different-verb">Tabs are modes because each mode is a different verb</h3>
<p>I name tabs <code class="language-plaintext highlighter-rouge">domain-modeling</code>, <code class="language-plaintext highlighter-rouge">implementation</code>, and <code class="language-plaintext highlighter-rouge">pr-reviews</code> because those are three different <em>jobs</em>. Each one comes with a different posture from me and a different register of prompt to the agent.</p>
<table>
<thead>
<tr>
<th>Tab</th>
<th>The verb</th>
<th>My posture</th>
<th>What the agent is allowed to do</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="language-plaintext highlighter-rouge">domain-modeling</code></td>
<td><strong>think</strong></td>
<td>Skeptical, Socratic</td>
<td>Read, reason, write a model. No code. Plan mode.</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">implementation</code></td>
<td><strong>change</strong></td>
<td>Directive, fenced</td>
<td>Edit inside an explicit scope, after an approved plan</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">pr-reviews</code></td>
<td><strong>judge</strong></td>
<td>Adversarial, evidentiary</td>
<td>Read the diff, report findings. No fixing.</td>
</tr>
</tbody>
</table>
<p>The failure mode I’m designing against is <strong>verb drift</strong>. You start a conversation reviewing a PR, you say “huh, good catch,” and three messages later the “reviewer” is rewriting the module, now with the confidence of something that just graded its own homework. Separate tabs, and separate agent sessions in them, make the verb change explicit. If I want a fix, I go to the implementation tab and ask for one there, with a scope. It’s the same rule I put on the read-only reviewer agents in <a href="/2026/08/20/plan-then-build/">Ask, Plan, Confirm: Making Agents Stop Before They Start</a>: the reviewer doesn’t get to “slide from ‘here is a finding’ into ‘and I fixed it’ without crossing the same line everyone else crosses.” The tab is that line, drawn on screen.</p>
<p>There’s a human benefit too. When I switch to a tab named <code class="language-plaintext highlighter-rouge">pr-reviews</code>, my brain switches hats before I’ve read a word. Mode labels are cheap and they work.</p>
<h3 id="a-pane-is-one-agent-one-context-window-one-job">A pane is one agent, one context window, one job</h3>
<p>Every agent pane gets <strong>one job for the life of that conversation</strong>. When the job is done, the conversation ends, and the next job gets a fresh agent. Context windows are big now, but they aren’t free and they aren’t clean. Every stale plan, abandoned approach, and “actually, ignore that” sits in there and pulls on the next answer. A pane per job is the cheapest context hygiene there is.</p>
<p>The panes <em>next</em> to an agent are there for <strong>verification, not decoration</strong>:</p>
<ul>
<li>In <code class="language-plaintext highlighter-rouge">domain-modeling</code>, the glossary and domain notes sit beside the agent, so I can check that it’s using the words we agreed on.</li>
<li>In <code class="language-plaintext highlighter-rouge">implementation</code>, a diffstat and a shell sit beside the agents, so I can see what <em>actually</em> changed, not what the agent says changed.</li>
<li>In <code class="language-plaintext highlighter-rouge">pr-reviews</code>, the raw diff sits beside the reviewer, so every <code class="language-plaintext highlighter-rouge">file:line</code> it cites can be checked by moving my eyes and nothing else.</li>
</ul>
<p><a href="/wp-content/uploads/2026/10/04-ultrawide-pr-reviews-tab-1.png"><img src="/wp-content/uploads/2026/10/04-ultrawide-pr-reviews-tab-1.png" alt="" /></a></p>
<p>Trust, but keep the evidence in the same field of view.</p>
<h3 id="tall-for-agents-short-for-shells-and-why-329-matters">Tall for agents, short for shells, and why 32:9 matters</h3>
<p>Agents emit long, structured output (plans, findings, tables), so they get full-height vertical splits. Shells and watchers are glanceable, so they get short horizontal stacks in a utility column. That isn’t a style choice. It’s matching pane shape to how much reading each pane needs.</p>
<p>The ultrawide makes it work without compromise. At 5120×1440 I get three or four full-height agent columns <em>and</em> the utility column <em>and</em> the sidebar, with nothing hidden behind a zoom or another tab. That matters for one reason above all: <strong>a blocked agent is a cost that accrues silently.</strong> Herdr’s sidebar tells me <em>that</em> something is blocked. Having it physically on screen tells me <em>why</em>, in peripheral vision, without moving. The less effort it takes to notice, the shorter the stall.</p>
<h2 id="part-2-keeping-claude-codex-and-cursor-effective-and-accurate">Part 2: Keeping Claude, Codex, and Cursor effective and accurate</h2>
<p>All of the above is plumbing. The water is language.</p>
<p>I’ve been on this soapbox a while. In <a href="/2024/12/09/precision-in-words-precision-in-code-the-power-of-writing-in-modern-development/">Precision in Words, Precision in Code: The Power of Writing in Modern Development</a> I argued that “Writing isn’t just ‘extra work’—it’s the work that clarifies, simplifies, and accelerates everything else,” and in <a href="/2025/02/25/ai-prompt-engineering-mastering-language-constructs/">AI Prompt Engineering: Mastering Language Constructs</a> I went into how the actual constructs, imperatives, conditionals, and contextual markers, shape what comes back. With agents that act, not just answer, the stakes go up. A vague word in a chat gets you a vague paragraph. A vague word in an agent prompt gets you a nine-file diff.</p>
<p>Here’s what I actually do. Every example below is a real prompt and a real response, captured for the screenshots in this repo with Claude Code in plan mode against throwaway demo projects.</p>
<h3 id="1-use-the-domains-words-and-ban-the-overloaded-ones">1. Use the domain’s words, and ban the overloaded ones</h3>
<p>Accurate language starts with <strong>the same word meaning the same thing every time</strong>: in the docs, in the code, in the prompt, in the review. That’s just <a href="https://martinfowler.com/bliki/UbiquitousLanguage.html">ubiquitous language</a> from domain-driven design, and agents need it more than humans do, because they fill every gap with the most <em>statistically likely</em> meaning, which is rarely <em>your</em> meaning.</p>
<p>So I keep a glossary in the notes repo (more on that in <a href="/2026/09/25/notes-repos-as-shared-project-context/">Notes Repos as Shared Project Context</a>, where the glossary is described as “cheap insurance against confident mistakes”), and I put the words <em>in the prompt</em>, including the ones that are off limits:</p>
<blockquote>
<p>Read docs/domain.md and internal/ledger/entry.go. Using only the domain terms <strong>Account, Entry, Journal, and Posting (never ‘transaction’)</strong>, describe a Posting aggregate: its invariants, what it owns, and what it must reject. Do not write code. Output a short markdown model.</p>
</blockquote>
<p>“Transaction” is banned because in a ledger it’s hopelessly overloaded: a database transaction, a business event, a bank-statement line. Pick one meaning, give it its own word, and forbid the ambiguous one. Here’s what came back:</p>
<p><a href="/wp-content/uploads/2026/10/11-crop-domain-model-output.png"><img src="/wp-content/uploads/2026/10/11-crop-domain-model-output.png" alt="" /></a></p>
<p>Two things to notice. The model sticks to Account, Entry, Journal, Posting the whole way through. And it <strong>marks which rules it inferred</strong> from general double-entry practice versus which came from the repo: “Rules marked <em>(inferred)</em> come from standard double-entry practice, not the repo.” That distinction is gold in domain modeling, and it showed up because the prompt was explicit about <em>where the truth lives</em> (those two files).</p>
<h3 id="2-lead-with-scope-as-the-first-word">2. Lead with scope, as the first word</h3>
<p>My implementation prompts literally start with the word <code class="language-plaintext highlighter-rouge">Scope:</code>.</p>
<blockquote>
<p><strong>Scope: cmd/main.go only.</strong> Plan a minimal CLI entrypoint that loads a Journal from a JSON file and prints the balance per Account. <strong>Do not touch internal/ledger.</strong> Keep the planned diff <strong>under 50 lines</strong>.</p>
</blockquote>
<p>Scope first, because everything after it gets read in its light. Then a positive fence (only this file), a negative fence (not that package), and a size limit stated as a number, not a vibe. “Keep it small” means nothing. “Under 50 lines” is checkable. I went deeper on why scope and diff size are the main levers in <a href="/2026/04/15/hurting-or-helping-devs/">Hurting or Helping Devs?</a>.</p>
<p>The payoff:</p>
<p><a href="/wp-content/uploads/2026/10/09-crop-agent-asks-about-scope-1.png"><img src="/wp-content/uploads/2026/10/09-crop-agent-asks-about-scope-1.png" alt="" /></a></p>
<p>The repo had no <code class="language-plaintext highlighter-rouge">go.mod</code>, so <code class="language-plaintext highlighter-rouge">cmd/main.go</code> <em>couldn’t</em> import <code class="language-plaintext highlighter-rouge">internal/ledger</code>. An unfenced agent “fixes” that by adding a module file, rewiring imports, and maybe stubbing a Journal type in the package I’d fenced off. This one <strong>stopped and asked</strong>, offered the in-scope option as the recommendation, and named the out-of-scope option <em>as</em> out of scope: “This touches a second file, which goes beyond the cmd/main.go-only scope.” Herdr flagged it blocked, and I could see it on the ultrawide without switching anything. That’s the whole system working: precise words create the stop, and the layout makes the stop cheap.</p>
<h3 id="3-pick-verbs-that-mean-exactly-one-thing">3. Pick verbs that mean exactly one thing</h3>
<p><code class="language-plaintext highlighter-rouge">plan</code>, <code class="language-plaintext highlighter-rouge">implement</code>, <code class="language-plaintext highlighter-rouge">review</code>, and <code class="language-plaintext highlighter-rouge">fix</code> are different verbs, and I never let one stand in for another.</p>
<ul>
<li><strong>Plan</strong> means produce a plan and change nothing. I back it with tooling: agents start in plan mode (<code class="language-plaintext highlighter-rouge">claude --permission-mode plan</code>; Codex and Cursor have their own read-only and ask-first modes), so the verb and the permission agree.</li>
<li><strong>Implement</strong> means change things inside the approved plan’s scope, and only after an explicit yes. Approval is scoped to the plan that was approved. This is the three-beat gate from <a href="/2026/08/20/plan-then-build/">Ask, Plan, Confirm</a>.</li>
<li><strong>Review</strong> means read and report. It doesn’t mean fix.</li>
<li><strong>Fix</strong> means a new implementation task, with its own scope, in the implementation tab.</li>
</ul>
<p>When the verb is precise, the agent’s job is precise, and so is my evaluation of whether it did the job.</p>
<h3 id="4-specify-the-shape-of-the-answer">4. Specify the shape of the answer</h3>
<p>If I can’t check an answer quickly, I’ll check it badly. So I specify the output format in a way that’s easy to verify against the pane next door:</p>
<blockquote>
<p>Review the diff main…feature/posting-aggregate. <strong>Classify every change as additive, mutative, or destructive.</strong> Flag any naming that drifts from docs/domain.md. Report findings as a <strong>numbered list with file:line</strong>. Do not propose rewrites outside the diff.</p>
</blockquote>
<p>“Additive, mutative, or destructive” isn’t decoration either. It’s the vocabulary from <a href="/2026/02/12/additive-vs-mutative-vs-destructive-code-changes-and-why-ai-agents-love-the-wrong-one-at-213am/">Additive vs. Mutative vs. Destructive Code Changes (and Why AI Agents Love the Wrong One at 2:13AM)</a>, and giving the reviewer that vocabulary makes it classify instead of just commenting.</p>
<p><a href="/wp-content/uploads/2026/10/10-crop-pr-review-findings.png"><img src="/wp-content/uploads/2026/10/10-crop-pr-review-findings.png" alt="" /></a></p>
<p>And because the question was precise, the reviewer had attention left over for what mattered. Under “side notes” it flagged that <strong>any <code class="language-plaintext highlighter-rouge">Direction</code> other than the exact string <code class="language-plaintext highlighter-rouge">"debit"</code>, including <code class="language-plaintext highlighter-rouge">""</code> or <code class="language-plaintext highlighter-rouge">"Debit"</code>, gets counted as a credit</strong>, and that <strong>an empty Posting passes validation as balanced</strong>. Neither is a naming issue. Both are exactly the kind of quiet behavioral drift that post warned about: code that looks right and behaves like an alien artifact. A vague “please review this PR” tends to bury that under eleven style nits.</p>
<p>The same pattern works for any review lens. On the storefront demo, “integer cents only, no floating point until display formatting. Numbered findings with file:line, <strong>severity first</strong>” produced a short, ranked list I could verify in about thirty seconds.</p>
<p><a href="/wp-content/uploads/2026/10/06-ultrawide-third-space-pr-review.png"><img src="/wp-content/uploads/2026/10/06-ultrawide-third-space-pr-review.png" alt="" /></a></p>
<h3 id="5-front-load-the-context-instead-of-looping-for-it">5. Front-load the context instead of looping for it</h3>
<p>A thin prompt plus five rounds of correction is worse than one well-fed prompt, and more expensive. I made that argument at length in <a href="/2026/07/15/loop-engineering-is-mostly-just-broken-sdlc-wearing-a-costume/">“Loop Engineering” Is Mostly Just Broken SDLC Wearing a Costume</a>: “A well-fed single pass beats a starved five-pass loop most of the time.” In practice that means every prompt names <strong>where the truth lives</strong>: the files to read, the glossary, the plan in the notes repo, the decision that was already made and shouldn’t be re-litigated.</p>
<p>It also means the <em>environment</em> has to be ready. An agent that can’t build, test, or reach the systems it needs will improvise, and improvisation is the opposite of accuracy. The checklist I use for that is in <a href="/2026/09/24/agent-ready-local-environments-and-mcp/">Agent-Ready Local Environments and MCP</a>: “An agent with broad access and no rules will eventually do something technically impressive and socially awful.”</p>
<h3 id="6-same-template-any-agent">6. Same template, any agent</h3>
<p>Claude Code, Codex, and Cursor have different personalities and different defaults, but the prompt <em>shape</em> carries across them. Herdr doesn’t care which one is in a pane. It detects them all, so I can put a different tool in the reviewer pane than the one that wrote the code. A second model has different blind spots, and that’s the point of a second opinion. The template:</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Scope: <span class="nt"><path</span><span class="err">(</span><span class="na">s</span><span class="err">)</span><span class="nt">></span> only. <span class="nt"><Do</span> <span class="na">not</span> <span class="na">touch:</span> <span class="na">path</span><span class="err">(</span><span class="na">s</span><span class="err">)</span><span class="nt">></span>.
Context: Read <span class="nt"><files</span> <span class="err">/</span> <span class="na">glossary</span> <span class="err">/</span> <span class="na">plan</span><span class="nt">></span>. Use the terms <span class="nt"><A</span><span class="err">,</span> <span class="na">B</span><span class="err">,</span> <span class="na">C</span><span class="nt">></span>; never <span class="nt"><overloaded</span> <span class="na">term</span><span class="nt">></span>.
Task (<span class="nt"><verb:</span> <span class="na">plan</span> <span class="err">|</span> <span class="na">implement</span> <span class="err">|</span> <span class="na">review</span><span class="nt">></span>): <span class="nt"><one</span> <span class="na">sentence</span><span class="err">,</span> <span class="na">one</span> <span class="na">job</span><span class="nt">></span>.
Constraints: <span class="nt"><additive</span> <span class="na">only</span> <span class="err">|</span> <span class="na">under</span> <span class="na">N</span> <span class="na">lines</span> <span class="err">|</span> <span class="na">no</span> <span class="na">new</span> <span class="na">dependencies</span> <span class="err">|</span> <span class="na">tests</span> <span class="na">first</span><span class="nt">></span>.
Output: <span class="nt"><numbered</span> <span class="na">list</span> <span class="err">|</span> <span class="na">file:line</span> <span class="err">|</span> <span class="na">severity</span> <span class="na">first</span> <span class="err">|</span> <span class="na">markdown</span> <span class="na">model</span> <span class="err">|</span> <span class="na">test</span> <span class="na">names</span> <span class="na">first</span><span class="nt">></span>.
If anything above is ambiguous or blocks you, stop and ask.
</code></pre></div></div>
<p>That last line matters most. It turns “the agent is stuck” from a silent failure into a <strong>blocked</strong> state, which Herdr turns into a dot I can see from across the room.</p>
<h3 id="7-when-an-agent-goes-blocked-or-done">7. When an agent goes blocked or done</h3>
<p>The loop on my side is short and boring, on purpose:</p>
<ol>
<li><strong>Blocked:</strong> read the question in place (it’s already on screen), answer it, and if the answer is a <em>decision</em>, write it into the notes repo’s decisions file so the next session doesn’t ask again.</li>
<li><strong>Done:</strong> read the output against the evidence pane next to it. If it’s a plan, approve it or correct it. If it’s findings, open a scoped implementation task in the other tab for anything worth fixing.</li>
<li><strong>Finished job:</strong> end that agent’s conversation. New job, new agent, clean context.</li>
</ol>
<h2 id="the-point-of-all-of-it">The point of all of it</h2>
<p>The spaces, tabs, panes, and ultrawide exist to keep <strong>context small, boundaries visible, and stalls cheap</strong>. The prompting exists to keep <strong>meaning precise</strong>. Neither works without the other. A gorgeous Herdr layout full of agents with vague instructions is just a very wide way to watch things go wrong in parallel, and the most precise prompt in the world doesn’t help if the agent has been blocked on it since lunch and you never noticed.</p>
<p>Draw the boundaries on screen. Draw them again in words. Then let the agents work.</p>
<h3 id="references">References</h3>
<p><strong>Herdr</strong></p>
<ul>
<li><a href="https://herdr.dev/">herdr.dev</a> · <a href="https://herdr.dev/docs/">Docs</a> · <a href="https://herdr.dev/docs/concepts/">Concepts</a> · <a href="https://herdr.dev/docs/agents/">Agents</a> · <a href="https://herdr.dev/docs/socket-api/">Socket API</a> · <a href="https://github.com/herdrdev/herdr">GitHub</a></li>
</ul>
<p><strong>Previously on Composite Code</strong></p>
<ul>
<li><a href="/2024/12/09/precision-in-words-precision-in-code-the-power-of-writing-in-modern-development/">Precision in Words, Precision in Code: The Power of Writing in Modern Development</a> (Dec 2024)</li>
<li><a href="/2025/02/25/ai-prompt-engineering-mastering-language-constructs/">AI Prompt Engineering: Mastering Language Constructs</a> (Feb 2025)</li>
<li><a href="/2025/11/04/architects-evolving-with-ai/">Architects Evolving: How AI Is Reshaping the Role</a> (Nov 2025)</li>
<li><a href="/2025/12/04/ai-is-forcing-docs-to-grow-up/">AI Is Forcing Docs To Finally Grow Up</a> (Dec 2025)</li>
<li><a href="/2026/02/12/additive-vs-mutative-vs-destructive-code-changes-and-why-ai-agents-love-the-wrong-one-at-213am/">Additive vs. Mutative vs. Destructive Code Changes (and Why AI Agents Love the Wrong One at 2:13AM)</a> (Feb 2026)</li>
<li><a href="/2026/04/15/hurting-or-helping-devs/">Hurting or Helping Devs?</a> (Apr 2026)</li>
<li><a href="/2026/05/26/the-gap-is-widening-and-its-not-slowing-down/">The Gap Is Widening and It’s Not Slowing Down</a> (May 2026)</li>
<li><a href="/2026/07/15/loop-engineering-is-mostly-just-broken-sdlc-wearing-a-costume/">“Loop Engineering” Is Mostly Just Broken SDLC Wearing a Costume</a> (Jul 2026)</li>
<li><a href="/2026/08/19/worktrees/">Giving Every Agent Its Own Branch: Git Worktrees for Parallel AI Work</a> (Aug 2026)</li>
<li><a href="/2026/08/20/plan-then-build/">Ask, Plan, Confirm: Making Agents Stop Before They Start</a> (Aug 2026)</li>
<li><a href="/2026/09/24/agent-ready-local-environments-and-mcp/">Agent-Ready Local Environments and MCP</a> (Sep 2026)</li>
<li><a href="/2026/09/25/notes-repos-as-shared-project-context/">Notes Repos as Shared Project Context</a> (Sep 2026)</li>
</ul>
<p><strong>Elsewhere</strong></p>
<ul>
<li>Martin Fowler, <a href="https://martinfowler.com/bliki/UbiquitousLanguage.html">Ubiquitous Language</a></li>
</ul>
Herding Agents: How I Actually Use Herdr (on a Ridiculously Wide Screen)
https://compositecode.blog/2026/10/09/herding-agents-how-i-actually-use-herdr-on-a-ridiculously-wide-screen/
2026-10-09T10:49:10-07:00
2026-10-09T10:55:21-07:00
Herdr is a terminal workspace manager designed for AI coding agents, enhancing productivity by organizing terminal sessions. It allows users to manage multiple agents efficiently, offering features like session handling, workspace creation, and agent state detection. Users can visually monitor agents’ statuses, prioritizing those needing input, facilitating seamless coding processes.
<p>One coding agent is a tool. Two is a pair. Six is a zoo, and somebody has to know which animal is chewing on the fence.</p>
<p>For most of this year that somebody was me, armed with a pile of terminal windows, a couple of tmux sessions I half remembered setting up, and the nagging feeling that some agent somewhere had been politely waiting on a yes/no question for twenty minutes. Agents don’t tap you on the shoulder. They sit there, cursor blinking, and burn the most expensive thing in the whole setup, which is wall-clock time.</p>
<p><a href="https://herdr.dev/">Herdr</a> fixed most of that for me. This post covers what it is, how it works, and how I lay it out day to day. There’s a companion post <strong>(TBD)</strong> on <em>why</em> I split things up the way I do and how I prompt inside the panes. This one is the tour.</p>
<p><a href="/wp-content/uploads/2026/10/01-ultrawide-domain-modeling-tab.png"><img src="/wp-content/uploads/2026/10/01-ultrawide-domain-modeling-tab.png" alt="A coding interface displaying a script on the left side and an output console on the right, with a dark theme and multiple lines of code written in a programming language." /></a></p>
<h2 id="what-herdr-is">What Herdr is</h2>
<p>Herdr bills itself as “the runtime your coding agents live on.” Less marketing, more mechanics: it’s a <strong>terminal workspace manager built for AI coding agents</strong>. If you’ve used tmux, you already have about 70% of the mental model. It’s:</p>
<ul>
<li><strong>A single Rust binary.</strong> No Electron and no browser tab pretending to be a terminal. <code class="language-plaintext highlighter-rouge">brew install herdr</code>, or <code class="language-plaintext highlighter-rouge">curl -fsSL https://herdr.dev/install.sh | sh</code>, and you’re done.</li>
<li><strong>Something that runs <em>inside</em> your terminal.</strong> iTerm, Ghostty, WezTerm, kitty, Alacritty, even inside tmux if you enjoy nesting dolls. It doesn’t replace your terminal. It organizes what’s in it.</li>
<li><strong>A client/server pair.</strong> A background server owns the terminal sessions. The thing you look at is just a client. Close the window, detach, drop SSH, and the agents keep working. Reattach and they’re right where you left them. (I learned this the fun way while building the screenshots for this post: the demo window got closed out from under me mid-run, and every agent was still sitting there when I reattached.)</li>
<li><strong>Agent-aware.</strong> This is the actual point. Herdr detects Claude Code, Codex, Cursor’s agent, Copilot CLI, OpenCode, Devin and a <a href="https://herdr.dev/docs/agents/">pile of others</a>, then classifies each one’s state so you don’t have to go look.</li>
</ul>
<h2 id="the-model-session--space--tab--pane">The model: session › space › tab › pane</h2>
<p>Herdr’s <a href="https://herdr.dev/docs/concepts/">concepts page</a> lays out four nouns. The precision matters, so here they are the way I think about them.</p>
<table>
<thead>
<tr>
<th>Noun</th>
<th>What Herdr says it is</th>
<th>What I use it for</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Session</strong></td>
<td>A persistent server namespace. <code class="language-plaintext highlighter-rouge">herdr</code> attaches to the default one; <code class="language-plaintext highlighter-rouge">herdr --session <name></code> gives you an isolated one.</td>
<td>My one long-lived “everything” session, plus throwaway named sessions when I’m experimenting (like the demo session for this post’s screenshots).</td>
</tr>
<tr>
<td><strong>Workspace</strong> (the sidebar calls them <em>spaces</em>)</td>
<td>“The top-level project container. Use one workspace per repo, task, or investigation.”</td>
<td><strong>One space per folder I’m working in.</strong> Full stop.</td>
</tr>
<tr>
<td><strong>Tab</strong></td>
<td>“A layout inside a workspace.”</td>
<td><strong>One tab per kind of work:</strong> <code class="language-plaintext highlighter-rouge">pr-reviews</code>, <code class="language-plaintext highlighter-rouge">domain-modeling</code>, <code class="language-plaintext highlighter-rouge">implementation</code>.</td>
</tr>
<tr>
<td><strong>Pane</strong></td>
<td>“A real terminal.”</td>
<td>One agent, or one shell, or one thing I’m watching. Arranged in vertical or horizontal splits.</td>
</tr>
</tbody>
</table>
<p>Then there are the <strong>agent states</strong>, which make the whole thing worth it:</p>
<ul>
<li><strong>Blocked:</strong> the agent needs input, approval, or a decision. <em>This is the one you care about.</em></li>
<li><strong>Working:</strong> it’s chewing.</li>
<li><strong>Done:</strong> it finished and you haven’t looked yet.</li>
<li><strong>Idle:</strong> it finished and you have looked.</li>
<li><strong>Unknown:</strong> Herdr can’t tell, usually because it’s a plain shell.</li>
</ul>
<p>Those states roll up into the sidebar. Each space gets a dot, and there’s an <strong>agents</strong> list underneath that shows every agent across every space. One glance and I know who’s blocked, who’s working, and who’s done and quietly waiting for me to read their output.</p>
<p><a href="/wp-content/uploads/2026/10/08-crop-sidebar-spaces-and-agents.png"><img src="/wp-content/uploads/2026/10/08-crop-sidebar-spaces-and-agents.png" alt="" /></a></p>
<p>That’s the shoulder tap agents never gave me.</p>
<h2 id="how-it-works-enough-to-be-dangerous">How it works (enough to be dangerous)</h2>
<h3 id="keyboard">Keyboard</h3>
<p>The default prefix is <code class="language-plaintext highlighter-rouge">ctrl+b</code>, deliberately tmux-flavored, and the keymap is “prefix-first” so Herdr doesn’t steal keystrokes from your shell, editor, or the agent. The ones I actually use:</p>
<table>
<thead>
<tr>
<th>Keys</th>
<th>Does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">v</code></td>
<td>Split vertical: new pane to the right, side by side</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">-</code></td>
<td>Split horizontal: new pane below, stacked</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">h</code> / <code class="language-plaintext highlighter-rouge">j</code> / <code class="language-plaintext highlighter-rouge">k</code> / <code class="language-plaintext highlighter-rouge">l</code></td>
<td>Move focus left / down / up / right</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">z</code></td>
<td>Zoom the current pane (and again to unzoom)</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">r</code></td>
<td>Resize mode</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">c</code></td>
<td>New tab</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">shift+t</code></td>
<td>Rename tab</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">1</code>…<code class="language-plaintext highlighter-rouge">9</code></td>
<td>Jump to tab N</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">shift+n</code></td>
<td>New workspace (space)</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">w</code></td>
<td>Workspace picker</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">b</code></td>
<td>Toggle the sidebar</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">q</code></td>
<td>Detach (everything keeps running)</td>
</tr>
<tr>
<td><code class="language-plaintext highlighter-rouge">prefix</code> <code class="language-plaintext highlighter-rouge">?</code></td>
<td>Help, for when you forget all of the above</td>
</tr>
</tbody>
</table>
<p>Run <code class="language-plaintext highlighter-rouge">herdr --default-config</code> to see the full annotated config, including how to remap any of it. Mouse works too: click, drag, split. I won’t judge.</p>
<h3 id="the-cli-and-socket-api">The CLI and socket API</h3>
<p>Here’s where it stops being “tmux with a status light.” Everything you can do with a keystroke you can also do from the command line over Herdr’s <a href="https://herdr.dev/docs/socket-api/">socket API</a>. That includes the agents themselves, since they’re running in panes Herdr owns. Every screenshot in this post came from a layout I scripted like this, in an isolated session so my real one never got touched:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># a space per folder
herdr workspace create --cwd ~/Codez/ledger-service --label ledger-service
# name the first tab for the kind of work happening in it
herdr tab rename w3:t1 domain-modeling
# agent on the left, reference material on the right, split again downward
herdr pane split w3:p1 --direction right --ratio 0.5
herdr pane split w3:p2 --direction down --ratio 0.5
# start an agent and hand it a scoped prompt
herdr pane run w3:p1 "claude --permission-mode plan"
herdr agent prompt w3:p1 "Read docs/domain.md and internal/ledger/entry.go. Using only the domain terms Account, Entry, Journal, and Posting (never 'transaction'), describe a Posting aggregate..."
# and then, the good part
herdr agent list # who is working / blocked / done
herdr agent wait w3:p1 --until blocked --until done # block until it needs me
herdr pane read w3:p1 # read what it said, without switching to it
</code></pre></div></div>
<p>That’s enough rope for agents to spawn panes, prompt <em>each other</em>, and notice when a sibling is blocked. I’ll admit that’s both very cool and the setup for a future post titled something like “How My Agents Unionized.”</p>
<h3 id="integrations">Integrations</h3>
<p><code class="language-plaintext highlighter-rouge">herdr integration install claude</code> (or <code class="language-plaintext highlighter-rouge">codex</code>, <code class="language-plaintext highlighter-rouge">copilot</code>, <code class="language-plaintext highlighter-rouge">opencode</code>, and so on) drops a small hook into each agent so it reports its lifecycle to Herdr directly, rather than Herdr inferring state by watching the screen. <code class="language-plaintext highlighter-rouge">herdr integration status</code> shows what’s wired up. Do this. The state dots get noticeably more accurate.</p>
<h3 id="the-rest-of-the-toolbox">The rest of the toolbox</h3>
<ul>
<li><strong>Worktrees:</strong> <code class="language-plaintext highlighter-rouge">herdr worktree create</code> makes a git worktree and opens it as its own space. It fits the one-branch-per-agent pattern I wrote about in <a href="/2026/08/19/worktrees/">Giving Every Agent Its Own Branch: Git Worktrees for Parallel AI Work</a>.</li>
<li><strong>Remote machines:</strong> <code class="language-plaintext highlighter-rouge">herdr --remote <ssh-target></code> puts panes on other boxes in the same sidebar, and they reconnect independently. See <a href="https://herdr.dev/docs/connecting-machines/">Connecting machines</a>.</li>
<li><strong>Session state:</strong> layouts persist and restore. Processes don’t survive a server restart (nothing does, that’s physics), but the shape of your workspace does. See <a href="https://herdr.dev/docs/session-state/">Session state</a>.</li>
</ul>
<h2 id="how-i-actually-use-it">How I actually use it</h2>
<p>That’s the brochure. Here’s what my screen looks like.</p>
<h3 id="one-space-per-folder">One space per folder</h3>
<p>Every folder I’m actively working in gets its own space, named after the folder. As I type this, my sidebar has ten of them: <code class="language-plaintext highlighter-rouge">interlinedlist</code>, then <code class="language-plaintext highlighter-rouge">interlinedlist-ios</code>, <code class="language-plaintext highlighter-rouge">interlinedlist-macos-native</code>, <code class="language-plaintext highlighter-rouge">interlinedlist-windows-app</code>, <code class="language-plaintext highlighter-rouge">interlinedlist-android</code>, <code class="language-plaintext highlighter-rouge">interlinedlist-architecture-migration</code>, a couple of client and side projects, and the repo this post lives in.</p>
<p>Is that a lot? Sure. Is it more than what’s actually in my head? No. It’s <em>exactly</em> what’s in my head, and that’s the point. The sidebar is a direct readout of my working memory. If it isn’t a folder I’m working in, it doesn’t get a space. When I stop working in a folder, the space goes away. My <code class="language-plaintext highlighter-rouge">config.toml</code> even sorts the agent list by space (<code class="language-plaintext highlighter-rouge">agent_panel_sort = "spaces"</code>) so the agents group the same way my brain does.</p>
<p>It’s mostly for my own mental organization, and I’m fine with that. The agents don’t care what the sidebar looks like. I do.</p>
<h3 id="one-tab-per-kind-of-work">One tab per kind of work</h3>
<p>Inside a space, tabs aren’t “more room.” Tabs are <strong>modes</strong>. I name them for the kind of work being done:</p>
<ul>
<li><strong><code class="language-plaintext highlighter-rouge">domain-modeling</code></strong>: one agent, plus the glossary, domain notes, and recent history open next to it. The agent’s job is to think, not type.</li>
<li><strong><code class="language-plaintext highlighter-rouge">implementation</code></strong>: one or more agents, each scoped to a slice of the code, with a diffstat or test runner and a plain shell beside them.</li>
<li><strong><code class="language-plaintext highlighter-rouge">pr-reviews</code></strong>: a reviewing agent next to the actual diff, so I can check its claims against the code without switching windows.</li>
</ul>
<p>Most spaces have just one tab, whichever mode that folder is in today. The busy ones have all three. Switching tabs is switching hats, and the tab label tells me which hat is on before I’ve read a single line.</p>
<p><a href="/wp-content/uploads/2026/10/02-ultrawide-implementation-tab.png"><img src="/wp-content/uploads/2026/10/02-ultrawide-implementation-tab.png" alt="" /></a>
<a href="/wp-content/uploads/2026/10/04-ultrawide-pr-reviews-tab.png"><img src="/wp-content/uploads/2026/10/04-ultrawide-pr-reviews-tab.png" alt="" /></a></p>
<h3 id="one-or-more-terminals-split-vertically-or-horizontally">One or more terminals, split vertically or horizontally</h3>
<p>Within a tab I keep a simple rule: <strong>agents get tall columns, shells get short stacks.</strong></p>
<p>Agents produce long, scrolling output: plans, numbered findings, diffs they want to show me. That wants height. So agents get vertical splits (<code class="language-plaintext highlighter-rouge">prefix v</code>), side by side, full height. Shells, test runners, <code class="language-plaintext highlighter-rouge">git log</code>, diffstats and log tails are glanceable. They get horizontal splits (<code class="language-plaintext highlighter-rouge">prefix -</code>) stacked in the right-hand column.</p>
<h3 id="the-ultrawide-changes-the-math">The ultrawide changes the math</h3>
<p>Here’s the cheat. I run all of this on a <strong>5120×1440, 32:9 ultrawide</strong>: two 2560×1440 monitors fused into one with no bezel down the middle. At normal terminal font sizes, that’s room for <strong>three or four full-height agent columns <em>plus</em> a stacked utility column and the sidebar</strong>, all visible at once, nobody squished.</p>
<p>On a regular 16:9 screen, that same implementation tab looks like this:</p>
<p><a href="/wp-content/uploads/2026/10/07-half-ultrawide-implementation-16x9-1.png"><img src="/wp-content/uploads/2026/10/07-half-ultrawide-implementation-16x9-1.png" alt="" /></a></p>
<p>Workable. Tight. Add a third agent and you’re zooming panes in and out all day. On the ultrawide it’s this:</p>
<p><a href="/wp-content/uploads/2026/10/02-ultrawide-implementation-tab-1.png"><img src="/wp-content/uploads/2026/10/02-ultrawide-implementation-tab-1.png" alt="" /></a></p>
<p>The difference isn’t really “more stuff.” It’s that <strong>nothing is hidden</strong>. When an agent goes blocked, I don’t need to find it. It’s already on screen, in my peripheral vision, with a red dot in the sidebar to back it up. Herdr tells me <em>who</em> needs attention. The ultrawide means I can <em>see</em> why without changing anything.</p>
<p><a href="/wp-content/uploads/2026/10/09-crop-agent-asks-about-scope.png"><img src="/wp-content/uploads/2026/10/09-crop-agent-asks-about-scope.png" alt="" /></a></p>
<p>That screenshot is an agent I’d told to touch <code class="language-plaintext highlighter-rouge">cmd/main.go</code> <em>only</em>. It found the repo had no <code class="language-plaintext highlighter-rouge">go.mod</code>, which meant staying in scope required a decision. So it stopped, went <strong>blocked</strong>, and asked. Herdr flagged it, and I could see the question without switching anything. Total cost: one keystroke. The alternative, an agent “helpfully” adding a module file and rewiring imports across a package I’d explicitly fenced off, costs a lot more than one keystroke. Why that worked, and how I write prompts so it keeps working, is the next post <strong>(TBD)</strong>.</p>
<h2 id="the-short-version">The short version</h2>
<ul>
<li><strong>Herdr</strong> is a terminal workspace manager for coding agents: one Rust binary, runs in your terminal, background server, agent-state detection.</li>
<li><strong>Spaces are folders.</strong> One per folder you’re working in. It’s a mirror of your head, not a filing cabinet.</li>
<li><strong>Tabs are modes of work.</strong> <code class="language-plaintext highlighter-rouge">pr-reviews</code>, <code class="language-plaintext highlighter-rouge">domain-modeling</code>, <code class="language-plaintext highlighter-rouge">implementation</code>.</li>
<li><strong>Panes:</strong> agents tall, shells short.</li>
<li><strong>The sidebar</strong> tells you who’s blocked. The <strong>ultrawide</strong> lets you see why without moving.</li>
</ul>
<p>Install it, run <code class="language-plaintext highlighter-rouge">herdr</code>, hit <code class="language-plaintext highlighter-rouge">ctrl+b ?</code>, and give it a day. Worst case, you’ve got a nice tmux. Best case, you stop finding agents that have been patiently blocked since lunch.</p>
<h3 id="references">References</h3>
<ul>
<li>Herdr: <a href="https://herdr.dev/">herdr.dev</a> · <a href="https://herdr.dev/docs/">Docs</a> · <a href="https://herdr.dev/docs/concepts/">Concepts</a> · <a href="https://herdr.dev/docs/configuration/">Configuration & keybindings</a> · <a href="https://herdr.dev/docs/agents/">Agents</a> · <a href="https://herdr.dev/docs/socket-api/">Socket API</a> · <a href="https://github.com/herdrdev/herdr">Source on GitHub</a></li>
<li>Related on Composite Code:
<ul>
<li><a href="/2026/08/19/worktrees/">Giving Every Agent Its Own Branch: Git Worktrees for Parallel AI Work</a></li>
<li><a href="/2026/08/20/plan-then-build/">Ask, Plan, Confirm: Making Agents Stop Before They Start</a></li>
<li><a href="/2026/09/11/20-terminal-commands/">Twenty-ish Terminal Commands for Getting Around the File System</a></li>
</ul>
</li>
<li>Screenshots were captured on Herdr 0.8.0 in an isolated <code class="language-plaintext highlighter-rouge">--session</code>, against throwaway demo repos, with Claude Code in plan mode (nothing was written to disk by the agents).</li>
</ul>
Notes Repos as Shared Project Context
https://compositecode.blog/2026/09/25/notes-repos-as-shared-project-context/
2026-09-25T15:01:30-07:00
2026-09-24T14:27:26-07:00
Creating a notes repository alongside code repositories enhances project clarity and continuity. It serves as a durable knowledge base for plans, decisions, and team structures, accessible to both human team members and agents. This organized approach minimizes miscommunication and maintains project focus, ensuring everyone stays aligned and informed throughout the development process.
<p>The first thing most people put in a new repository is code. That is fine. It is also incomplete.</p>
<p>When I start a real project these days, something I expect Cursor, Claude, Codex, and I to grind on together for more than an afternoon, I create a notes repo early. Sometimes it sits beside the product repo. Sometimes it lives inside the product repo as a deliberate <code class="language-plaintext highlighter-rouge">notes/</code> or <code class="language-plaintext highlighter-rouge">docs/project/</code> tree. Either way, the job is the same: give the humans and the agents one durable place to put the stuff that does not belong in source files but absolutely belongs in the work.</p>
<p>This is not documentation for show. It is shared working memory.</p>
<h2 id="why-a-notes-repo-not-a-pile-of-chat">Why a notes repo, not a pile of chat</h2>
<p>Chat is ephemeral. Agent transcripts are useful, but they are not a system of record. Plans get buried. Decisions get restated wrong three sessions later. Somebody new (human or model) shows up and has to rediscover who owns what, what “done” means, and which external systems are in play.</p>
<p>A notes repo fixes that by making the context something you can open and search. Cursor can open it. Claude can read it. Codex can search it. I can edit it when the plan changes. Everyone is looking at the same markdown, not reconstructing the project from memory and half-remembered Slack threads.</p>
<p>I treat it as the base layer under the code. The product repo holds the build. The notes repo holds the thinking that keeps the build pointed in the right direction.</p>
<h2 id="what-actually-goes-in-it">What actually goes in it</h2>
<p>I keep the structure boring on purpose. Fancy wikis rot. Flat, named markdown files get used.</p>
<p><strong>Project brief.</strong> What we are building, who it is for, what it is not. One page. If you cannot say it in a page, you do not have a project yet. You have a mood.</p>
<p><strong>Current plan.</strong> The active plan, not a museum of every plan that ever existed. Short enough to approve in one breath. Specific enough that “yes” means something. When the plan changes, rewrite it. Do not append a novel of abandoned approaches unless those approaches teach a real constraint.</p>
<p><strong>Decisions.</strong> Lightweight ADRs, or just dated notes: we chose X because Y, and Z is explicitly out of scope. Agents love to re-litigate settled choices. Write the settlement down.</p>
<p><strong>Team map.</strong> Who is who. Humans, roles, ownership. Which agent lanes exist if you have specialized agents. Who reviews security. Who can approve schema changes. “The team” includes the tools now, so name them and their jobs.</p>
<p><strong>Glossary.</strong> Product words, domain words, the three acronyms everybody uses differently. Cheap insurance against confident mistakes.</p>
<p><strong>Working notes.</strong> Scratch space for the current thread of work: open questions, links to tickets, sketches, “we tried this and it failed because…”. This is the joint notebook Cursor, Claude, Codex, and I write into while we work.</p>
<p><strong>Pointers out.</strong> Links to the product repos, design files, staging URLs, runbooks, and the environment/setup notes. The notes repo should know where the rest of the world lives without trying to duplicate it.</p>
<p>I do not put secrets here. I put the names of secrets and where they are supposed to live. That distinction matters.</p>
<h2 id="how-i-use-it-with-agents">How I use it with agents</h2>
<p>The pattern is simple: before an agent starts implementing, it reads the brief, the current plan, and the relevant decision notes. After a meaningful chunk of work, it updates the working notes or the plan so the next session does not start from zero.</p>
<p>That pairs cleanly with the ask-plan-confirm habit I already use. The notes repo is where the approved plan lives between sessions. The agent does not get to invent a new goal because the chat scrolled away.</p>
<p>A few practical rules that hold up:</p>
<ul>
<li>One current plan file. Archive old plans if you must, but do not make the agent guess which file is live.</li>
<li>Prefer short files with clear names over one giant <code class="language-plaintext highlighter-rouge">NOTES.md</code>.</li>
<li>Write for the next reader who was not in the room. That reader might be you in two weeks, or an agent with a fresh context window.</li>
<li>Update notes as part of the work, not as a cleanup chore after merge. If it is optional, it will not happen.</li>
</ul>
<h2 id="what-this-saves">What this saves</h2>
<p>It saves re-explaining the project every morning. It saves the “wait, who owns auth?” loop. It saves agents from optimizing the wrong goal because the real goal lived in a Slack thread from Thursday.</p>
<p>It also saves <em>me</em> from being the only continuity process on the team. Continuity is a file. Version it. Diff it. Argue with it in a pull request if the decision is big enough.</p>
<h2 id="start-smaller-than-you-think">Start smaller than you think</h2>
<p>You do not need a knowledge management platform. You need a repo, a README that says what the repo is for, and a handful of markdown files that stay honest.</p>
<p>Create it when the project becomes real. Keep it next to the code in your mental model. Make every agent treat it as required reading before they touch the product tree.</p>
<p>Code is the artifact. Notes are the shared brain that keeps the artifact from wandering off.</p>
Toggle Switches When the Thing Behind the Switch Is a Whole System
https://compositecode.blog/2026/09/24/toggle-switches-when-the-thing-behind-the-switch-is-a-whole-system/
2026-09-24T18:00:31-07:00
2026-09-24T18:00:31-07:00
The content discusses the complexities of implementing feature toggles in software development, using three examples: a data pipeline switch, an expedited shipping offer, and a saved filters interface. It emphasizes understanding ownership, failure modes, and scope, and highlights the importance of maintaining clear boundaries and decision-making processes to prevent confusion and errors during development.
<p>There is a version of a feature toggle that looks wonderfully simple in a pull request:</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="nx">features</span><span class="p">.</span><span class="nx">newThing</span><span class="p">)</span> <span class="p">{</span>
<span class="nf">doNewThing</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>
<p>Then <code class="language-plaintext highlighter-rouge">newThing</code> turns out to be a data pipeline, a shipping promise, or a screen people have already started using. Now the switch has an owner, a scope, a failure mode, and a date when we ought to remove it. The <code class="language-plaintext highlighter-rouge">if</code> statement is the least interesting part.</p>
<p>I want to work through three concrete examples. The first moves a flat-file order feed through Databricks, then cuts a tenant over to normalized PostgreSQL behind a data API. The second turns on expedited shipping, with the same decision implemented in TypeScript and Go. The third releases a saved-filters interface in React and SwiftUI. Each is a different kind of switch, and each can surprise you if you treat it as a Boolean sprinkled through the codebase.</p>
<p>The examples are deliberately small enough to read, but the boundaries are real: immutable inputs, idempotency, tenant scoping, stable rollout decisions, and the difference between hiding a button and actually controlling a capability. Let’s get into it.</p>
<h2 id="1-the-order-feed-databricks-today-postgresql-data-api-tomorrow">1. The order feed: Databricks today, PostgreSQL data API tomorrow</h2>
<p>Imagine a partner dropping one immutable CSV file per batch into object storage. Each row is an item on an order:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>order_id,customer_id,customer_name,sku,quantity,event_at
o-100,c-7,Ada,rail-pass,2,2026-09-24T10:00:00Z
o-100,c-7,Ada,seat-upgrade,1,2026-09-24T10:00:00Z
o-101,c-8,Grace,rail-pass,1,2026-09-24T10:01:00Z
</code></pre></div></div>
<p>The existing path loads the file into a Databricks Delta bronze table, then produces a current-order table for queries. The proposed path reads that <em>same</em> object, validates it, and sends a complete batch to a private data API. The API writes three normalized PostgreSQL tables: <code class="language-plaintext highlighter-rouge">customers</code>, <code class="language-plaintext highlighter-rouge">orders</code>, and <code class="language-plaintext highlighter-rouge">order_items</code>.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>immutable CSV in object storage
|
batch worker
|
tenant route snapshot
/ \
Databricks PostgreSQL data API
COPY INTO validate + transaction
bronze/curated customers/orders/items
\ /
read adapter for the tenant
</code></pre></div></div>
<p>The switch is a <strong>tenant route</strong>, not a random choice made separately for each row. For a given batch, resolve the route once and put it in the batch log. Changing the route while a file is half processed would create an impressively confusing incident.</p>
<h3 id="the-route-and-the-file-contract">The route and the file contract</h3>
<p>I keep the control-plane data separate from the pipeline code. In this example the configuration is a checked, versioned snapshot supplied to the worker; in production it might come from a flag service. The important bit is that the worker receives one decision and uses it for the whole operation.</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pipeline/route.ts</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">PipelinePath</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">databricks</span><span class="dl">"</span> <span class="o">|</span> <span class="dl">"</span><span class="s2">postgres</span><span class="dl">"</span><span class="p">;</span>
<span class="k">export</span> <span class="kr">interface</span> <span class="nx">RouteSnapshot</span> <span class="p">{</span>
<span class="nl">version</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
<span class="nl">defaultPath</span><span class="p">:</span> <span class="nx">PipelinePath</span><span class="p">;</span>
<span class="nl">tenants</span><span class="p">:</span> <span class="nb">Record</span><span class="o"><</span><span class="kr">string</span><span class="p">,</span> <span class="nx">PipelinePath</span><span class="o">></span><span class="p">;</span>
<span class="p">}</span>
<span class="k">export</span> <span class="kr">interface</span> <span class="nx">BatchRef</span> <span class="p">{</span>
<span class="nl">tenantId</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">batchId</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">fileName</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="c1">// a basename under the tenant's immutable S3 prefix</span>
<span class="nl">sha256</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="c1">// digest of the file's bytes, recorded when uploaded</span>
<span class="p">}</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">choosePath</span><span class="p">(</span>
<span class="nx">config</span><span class="p">:</span> <span class="nx">RouteSnapshot</span><span class="p">,</span>
<span class="nx">tenantId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="p">):</span> <span class="p">{</span> <span class="nl">path</span><span class="p">:</span> <span class="nx">PipelinePath</span><span class="p">;</span> <span class="nl">configVersion</span><span class="p">:</span> <span class="kr">number</span> <span class="p">}</span> <span class="p">{</span>
<span class="k">return</span> <span class="p">{</span>
<span class="na">path</span><span class="p">:</span> <span class="nx">config</span><span class="p">.</span><span class="nx">tenants</span><span class="p">[</span><span class="nx">tenantId</span><span class="p">]</span> <span class="o">??</span> <span class="nx">config</span><span class="p">.</span><span class="nx">defaultPath</span><span class="p">,</span>
<span class="na">configVersion</span><span class="p">:</span> <span class="nx">config</span><span class="p">.</span><span class="nx">version</span><span class="p">,</span>
<span class="p">};</span>
<span class="p">}</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">checkBatchRef</span><span class="p">(</span><span class="nx">batch</span><span class="p">:</span> <span class="nx">BatchRef</span><span class="p">):</span> <span class="k">void</span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="sr">/^</span><span class="se">[</span><span class="sr">a-z0-9-</span><span class="se">]{1,64}</span><span class="sr">$/</span><span class="p">.</span><span class="nf">test</span><span class="p">(</span><span class="nx">batch</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">)</span> <span class="o">||</span>
<span class="o">!</span><span class="sr">/^</span><span class="se">[</span><span class="sr">a-zA-Z0-9-</span><span class="se">]{1,100}</span><span class="sr">$/</span><span class="p">.</span><span class="nf">test</span><span class="p">(</span><span class="nx">batch</span><span class="p">.</span><span class="nx">batchId</span><span class="p">)</span> <span class="o">||</span>
<span class="o">!</span><span class="sr">/^</span><span class="se">[</span><span class="sr">a-zA-Z0-9-</span><span class="se">]</span><span class="sr">+</span><span class="se">\.</span><span class="sr">csv$/</span><span class="p">.</span><span class="nf">test</span><span class="p">(</span><span class="nx">batch</span><span class="p">.</span><span class="nx">fileName</span><span class="p">)</span> <span class="o">||</span>
<span class="o">!</span><span class="sr">/^</span><span class="se">[</span><span class="sr">a-f0-9</span><span class="se">]{64}</span><span class="sr">$/</span><span class="p">.</span><span class="nf">test</span><span class="p">(</span><span class="nx">batch</span><span class="p">.</span><span class="nx">sha256</span><span class="p">))</span> <span class="p">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Invalid batch reference</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>The file key is a basename by design. The worker constructs the object path from a fixed bucket and tenant prefix. That keeps a caller from turning a batch submission into “please read whatever URL I hand you.” The SHA-256 is about replay identity: <code class="language-plaintext highlighter-rouge">batch-42</code> with new bytes is an error, not a cute way to overwrite history.</p>
<h3 id="the-existing-databricks-path">The existing Databricks path</h3>
<p>Here is the setup on the lakehouse side. The external location and warehouse already have access to the bucket. I’m showing Databricks SQL because <code class="language-plaintext highlighter-rouge">COPY INTO</code> is a good fit for an incremental feed of files, and the loaded-file tracking makes retries tractable. If your feed is millions of files, Databricks points you toward Auto Loader instead.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> <span class="n">main</span><span class="p">.</span><span class="n">orders</span><span class="p">.</span><span class="n">order_rows_bronze</span> <span class="p">(</span>
<span class="n">order_id</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">customer_id</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">customer_name</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">sku</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">quantity</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">event_at</span> <span class="n">STRING</span>
<span class="p">)</span> <span class="k">USING</span> <span class="n">DELTA</span><span class="p">;</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> <span class="n">main</span><span class="p">.</span><span class="n">orders</span><span class="p">.</span><span class="n">order_rows_current</span> <span class="p">(</span>
<span class="n">order_id</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">customer_id</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">customer_name</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">sku</span> <span class="n">STRING</span><span class="p">,</span>
<span class="n">quantity</span> <span class="nb">INT</span><span class="p">,</span>
<span class="n">event_at</span> <span class="nb">TIMESTAMP</span>
<span class="p">)</span> <span class="k">USING</span> <span class="n">DELTA</span><span class="p">;</span>
</code></pre></div></div>
<p>The TypeScript adapter submits a SQL statement to a warehouse and waits for completion. For this example every query returns either no rows or a small lookup result; large query results need the API’s external-links disposition and a separate paging design.</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pipeline/databricks.ts</span>
<span class="kd">type</span> <span class="nx">StatementResult</span> <span class="o">=</span> <span class="p">{</span>
<span class="nx">statement_id</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">status</span><span class="p">:</span> <span class="p">{</span> <span class="na">state</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">error</span><span class="p">?:</span> <span class="p">{</span> <span class="na">message</span><span class="p">:</span> <span class="kr">string</span> <span class="p">}</span> <span class="p">};</span>
<span class="nl">result</span><span class="p">?:</span> <span class="p">{</span> <span class="nx">data_array</span><span class="p">?:</span> <span class="kr">string</span><span class="p">[][]</span> <span class="p">};</span>
<span class="p">};</span>
<span class="k">export</span> <span class="kd">class</span> <span class="nc">DatabricksSql</span> <span class="p">{</span>
<span class="nf">constructor</span><span class="p">(</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="nx">host</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="nx">token</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="k">private</span> <span class="k">readonly</span> <span class="nx">warehouseId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="p">)</span> <span class="p">{}</span>
<span class="k">private</span> <span class="k">async</span> <span class="nf">request</span><span class="p">(</span><span class="nx">path</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">init</span><span class="p">?:</span> <span class="nx">RequestInit</span><span class="p">):</span> <span class="nb">Promise</span><span class="o"><</span><span class="nx">StatementResult</span><span class="o">></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="k">this</span><span class="p">.</span><span class="nx">host</span><span class="p">}${</span><span class="nx">path</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="p">{</span>
<span class="p">...</span><span class="nx">init</span><span class="p">,</span>
<span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
<span class="na">Authorization</span><span class="p">:</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="k">this</span><span class="p">.</span><span class="nx">token</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">,</span>
<span class="p">...</span><span class="nx">init</span><span class="p">?.</span><span class="nx">headers</span><span class="p">,</span>
<span class="p">},</span>
<span class="p">});</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="s2">`Databricks HTTP </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">()</span> <span class="kd">as </span><span class="nb">Promise</span><span class="o"><</span><span class="nx">StatementResult</span><span class="o">></span><span class="p">;</span>
<span class="p">}</span>
<span class="k">async</span> <span class="nf">execute</span><span class="p">(</span><span class="nx">statement</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span> <span class="nx">parameters</span><span class="p">:</span> <span class="p">{</span> <span class="nl">name</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">value</span><span class="p">:</span> <span class="kr">string</span> <span class="p">}[]</span> <span class="o">=</span> <span class="p">[])</span> <span class="p">{</span>
<span class="kd">let</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">request</span><span class="p">(</span><span class="dl">"</span><span class="s2">/api/2.0/sql/statements</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
<span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
<span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">({</span>
<span class="na">warehouse_id</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">warehouseId</span><span class="p">,</span>
<span class="nx">statement</span><span class="p">,</span>
<span class="nx">parameters</span><span class="p">,</span>
<span class="na">wait_timeout</span><span class="p">:</span> <span class="dl">"</span><span class="s2">10s</span><span class="dl">"</span><span class="p">,</span>
<span class="na">disposition</span><span class="p">:</span> <span class="dl">"</span><span class="s2">INLINE</span><span class="dl">"</span><span class="p">,</span>
<span class="na">format</span><span class="p">:</span> <span class="dl">"</span><span class="s2">JSON_ARRAY</span><span class="dl">"</span><span class="p">,</span>
<span class="p">}),</span>
<span class="p">});</span>
<span class="kd">const</span> <span class="nx">deadline</span> <span class="o">=</span> <span class="nb">Date</span><span class="p">.</span><span class="nf">now</span><span class="p">()</span> <span class="o">+</span> <span class="mi">120</span><span class="nx">_000</span><span class="p">;</span>
<span class="k">while </span><span class="p">(</span><span class="nx">result</span><span class="p">.</span><span class="nx">status</span><span class="p">.</span><span class="nx">state</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">PENDING</span><span class="dl">"</span> <span class="o">||</span> <span class="nx">result</span><span class="p">.</span><span class="nx">status</span><span class="p">.</span><span class="nx">state</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">RUNNING</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">result</span><span class="p">.</span><span class="nx">statement_id</span> <span class="o">||</span> <span class="nb">Date</span><span class="p">.</span><span class="nf">now</span><span class="p">()</span> <span class="o">></span> <span class="nx">deadline</span><span class="p">)</span> <span class="p">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Databricks statement exceeded worker deadline</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">await</span> <span class="k">new</span> <span class="nc">Promise</span><span class="p">(</span><span class="nx">resolve</span> <span class="o">=></span> <span class="nf">setTimeout</span><span class="p">(</span><span class="nx">resolve</span><span class="p">,</span> <span class="mi">1</span><span class="nx">_000</span><span class="p">));</span>
<span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nf">request</span><span class="p">(</span><span class="s2">`/api/2.0/sql/statements/</span><span class="p">${</span><span class="nx">result</span><span class="p">.</span><span class="nx">statement_id</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">if </span><span class="p">(</span><span class="nx">result</span><span class="p">.</span><span class="nx">status</span><span class="p">.</span><span class="nx">state</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">SUCCEEDED</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="nx">result</span><span class="p">.</span><span class="nx">status</span><span class="p">.</span><span class="nx">error</span><span class="p">?.</span><span class="nx">message</span> <span class="o">??</span> <span class="s2">`Statement </span><span class="p">${</span><span class="nx">result</span><span class="p">.</span><span class="nx">status</span><span class="p">.</span><span class="nx">state</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">result</span><span class="p">.</span><span class="nx">result</span><span class="p">?.</span><span class="nx">data_array</span> <span class="o">??</span> <span class="p">[];</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">loadDatabricksBatch</span><span class="p">(</span>
<span class="nx">sql</span><span class="p">:</span> <span class="nx">DatabricksSql</span><span class="p">,</span>
<span class="nx">batch</span><span class="p">:</span> <span class="nx">BatchRef</span><span class="p">,</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o"><</span><span class="k">void</span><span class="o">></span> <span class="p">{</span>
<span class="nf">checkBatchRef</span><span class="p">(</span><span class="nx">batch</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">prefix</span> <span class="o">=</span> <span class="s2">`s3://example-order-feed/</span><span class="p">${</span><span class="nx">batch</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">}</span><span class="s2">/`</span><span class="p">;</span>
<span class="c1">// Only the basename is interpolated; checkBatchRef restricts its alphabet.</span>
<span class="k">await</span> <span class="nx">sql</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span><span class="s2">`
COPY INTO main.orders.order_rows_bronze
FROM '</span><span class="p">${</span><span class="nx">prefix</span><span class="p">}</span><span class="s2">'
FILEFORMAT = CSV
FILES = ('</span><span class="p">${</span><span class="nx">batch</span><span class="p">.</span><span class="nx">fileName</span><span class="p">}</span><span class="s2">')
FORMAT_OPTIONS ('header' = 'true')
`</span><span class="p">);</span>
<span class="c1">// The source file is a complete order snapshot. Deduplicate source rows</span>
<span class="c1">// before MERGE: two matching source rows for one target key are ambiguous.</span>
<span class="k">await</span> <span class="nx">sql</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span><span class="s2">`
MERGE INTO main.orders.order_rows_current AS target
USING (
SELECT order_id, customer_id, customer_name, sku,
CAST(quantity AS INT) AS quantity,
CAST(event_at AS TIMESTAMP) AS event_at
FROM main.orders.order_rows_bronze
QUALIFY ROW_NUMBER() OVER (
PARTITION BY order_id, sku ORDER BY CAST(event_at AS TIMESTAMP) DESC
) = 1
) AS source
ON target.order_id = source.order_id AND target.sku = source.sku
WHEN MATCHED AND source.event_at >= target.event_at THEN UPDATE SET
customer_id = source.customer_id,
customer_name = source.customer_name,
quantity = source.quantity,
event_at = source.event_at
WHEN NOT MATCHED THEN INSERT
(order_id, customer_id, customer_name, sku, quantity, event_at)
VALUES (source.order_id, source.customer_id, source.customer_name,
source.sku, source.quantity, source.event_at)
`</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>
<p>There is a deliberate simplification here: the merge scans bronze and models upserts, not item deletion. If a later snapshot can remove an item, the source contract needs tombstones or a replace-whole-order operation. No toggle solves an undefined deletion contract. Also, in a real lakehouse I would key these tables by tenant, or put each tenant in its own governed schema; the sample’s <code class="language-plaintext highlighter-rouge">order_id</code> must be globally unique for the shown merge.</p>
<h3 id="the-postgresql-path-and-its-data-api">The PostgreSQL path and its data API</h3>
<p>On the new path, the database is an implementation detail of the data API. The worker never gets a PostgreSQL connection string. A private, authenticated service receives a complete batch; the service owns validation, idempotency, and the transaction.</p>
<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">customers</span> <span class="p">(</span>
<span class="n">tenant_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">customer_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">customer_name</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">customer_id</span><span class="p">)</span>
<span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders</span> <span class="p">(</span>
<span class="n">tenant_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">order_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">customer_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">event_at</span> <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">order_id</span><span class="p">),</span>
<span class="k">FOREIGN</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">customer_id</span><span class="p">)</span>
<span class="k">REFERENCES</span> <span class="n">customers</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">customer_id</span><span class="p">)</span>
<span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">order_items</span> <span class="p">(</span>
<span class="n">tenant_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">order_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">sku</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">quantity</span> <span class="nb">integer</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">CHECK</span> <span class="p">(</span><span class="n">quantity</span> <span class="o">></span> <span class="mi">0</span><span class="p">),</span>
<span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="n">sku</span><span class="p">),</span>
<span class="k">FOREIGN</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">order_id</span><span class="p">)</span>
<span class="k">REFERENCES</span> <span class="n">orders</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">order_id</span><span class="p">)</span> <span class="k">ON</span> <span class="k">DELETE</span> <span class="k">CASCADE</span>
<span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">ingestion_batches</span> <span class="p">(</span>
<span class="n">tenant_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">batch_id</span> <span class="nb">text</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">sha256</span> <span class="nb">char</span><span class="p">(</span><span class="mi">64</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
<span class="n">committed_at</span> <span class="n">timestamptz</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">DEFAULT</span> <span class="n">now</span><span class="p">(),</span>
<span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="p">(</span><span class="n">tenant_id</span><span class="p">,</span> <span class="n">batch_id</span><span class="p">)</span>
<span class="p">);</span>
</code></pre></div></div>
<p>The flat file repeats customer names and order IDs. The relational model stores each customer and order once, then each item under that order. This normalization is useful for operational queries and constraints; it is not a claim that PostgreSQL is automatically the better analytics engine. The switch is about the workload we actually need to serve.</p>
<p>The worker downloads the immutable object and parses it. This uses <code class="language-plaintext highlighter-rouge">@aws-sdk/client-s3</code> and <code class="language-plaintext highlighter-rouge">csv-parse/sync</code>. I cap the file at 10,000 rows here so the data API can handle one transaction; larger feeds should use bounded chunks with a manifest and a stronger commit protocol.</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pipeline/postgres-path.ts</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">S3Client</span><span class="p">,</span> <span class="nx">GetObjectCommand</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@aws-sdk/client-s3</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">parse</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">csv-parse/sync</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">createHash</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">node:crypto</span><span class="dl">"</span><span class="p">;</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">OrderRow</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">order_id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">customer_id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">customer_name</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">sku</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">quantity</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
<span class="nl">event_at</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">};</span>
<span class="kd">const</span> <span class="nx">s3</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">S3Client</span><span class="p">({});</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">loadPostgresBatch</span><span class="p">(</span>
<span class="nx">batch</span><span class="p">:</span> <span class="nx">BatchRef</span><span class="p">,</span>
<span class="nx">apiBase</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="nx">serviceToken</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o"><</span><span class="k">void</span><span class="o">></span> <span class="p">{</span>
<span class="nf">checkBatchRef</span><span class="p">(</span><span class="nx">batch</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">key</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">batch</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">}</span><span class="s2">/</span><span class="p">${</span><span class="nx">batch</span><span class="p">.</span><span class="nx">fileName</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">object</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">s3</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="k">new</span> <span class="nc">GetObjectCommand</span><span class="p">({</span>
<span class="na">Bucket</span><span class="p">:</span> <span class="dl">"</span><span class="s2">example-order-feed</span><span class="dl">"</span><span class="p">,</span> <span class="na">Key</span><span class="p">:</span> <span class="nx">key</span><span class="p">,</span>
<span class="p">}));</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">object</span><span class="p">.</span><span class="nx">Body</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Empty object body</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">bytes</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">object</span><span class="p">.</span><span class="nx">Body</span><span class="p">.</span><span class="nf">transformToByteArray</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">digest</span> <span class="o">=</span> <span class="nf">createHash</span><span class="p">(</span><span class="dl">"</span><span class="s2">sha256</span><span class="dl">"</span><span class="p">).</span><span class="nf">update</span><span class="p">(</span><span class="nx">bytes</span><span class="p">).</span><span class="nf">digest</span><span class="p">(</span><span class="dl">"</span><span class="s2">hex</span><span class="dl">"</span><span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">digest</span> <span class="o">!==</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">sha256</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Batch content changed</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">records</span> <span class="o">=</span> <span class="nf">parse</span><span class="p">(</span><span class="nx">Buffer</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">bytes</span><span class="p">),</span> <span class="p">{</span>
<span class="na">columns</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="na">skip_empty_lines</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="na">bom</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">})</span> <span class="kd">as </span><span class="nb">Record</span><span class="o"><</span><span class="kr">string</span><span class="p">,</span> <span class="kr">string</span><span class="o">></span><span class="p">[];</span>
<span class="k">if </span><span class="p">(</span><span class="nx">records</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span> <span class="o">||</span> <span class="nx">records</span><span class="p">.</span><span class="nx">length</span> <span class="o">></span> <span class="mi">10</span><span class="nx">_000</span><span class="p">)</span> <span class="p">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Batch size outside accepted range</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="na">rows</span><span class="p">:</span> <span class="nx">OrderRow</span><span class="p">[]</span> <span class="o">=</span> <span class="nx">records</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="nx">row</span> <span class="o">=></span> <span class="p">({</span>
<span class="na">order_id</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">order_id</span><span class="p">,</span>
<span class="na">customer_id</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">customer_id</span><span class="p">,</span>
<span class="na">customer_name</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">customer_name</span><span class="p">,</span>
<span class="na">sku</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">sku</span><span class="p">,</span>
<span class="na">quantity</span><span class="p">:</span> <span class="nc">Number</span><span class="p">(</span><span class="nx">row</span><span class="p">.</span><span class="nx">quantity</span><span class="p">),</span>
<span class="na">event_at</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">event_at</span><span class="p">,</span>
<span class="p">}));</span>
<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">apiBase</span><span class="p">}</span><span class="s2">/v1/batches`</span><span class="p">,</span> <span class="p">{</span>
<span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
<span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
<span class="na">Authorization</span><span class="p">:</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="nx">serviceToken</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">,</span>
<span class="p">},</span>
<span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">stringify</span><span class="p">({</span> <span class="p">...</span><span class="nx">batch</span><span class="p">,</span> <span class="nx">rows</span> <span class="p">}),</span>
<span class="p">});</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="s2">`Data API rejected batch: </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>
<p>The API side is where the transaction belongs. <code class="language-plaintext highlighter-rouge">zod</code> checks the wire shape; PostgreSQL constraints still carry the final integrity guarantee. The gateway authenticates the service token and supplies the tenant identity; it must verify that the tenant in the request body matches that identity. I’m leaving gateway wiring out of this excerpt, but I would not expose this route to the public internet as an unauthenticated import endpoint.</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// data-api/batches.ts (Fastify + pg + zod)</span>
<span class="k">import</span> <span class="nx">Fastify</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">fastify</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">pg</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">pg</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">z</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">zod</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">pool</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">pg</span><span class="p">.</span><span class="nc">Pool</span><span class="p">({</span> <span class="na">connectionString</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">DATABASE_URL</span> <span class="p">});</span>
<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nc">Fastify</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">rowSchema</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nf">object</span><span class="p">({</span>
<span class="na">order_id</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">max</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
<span class="na">customer_id</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">max</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
<span class="na">customer_name</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">max</span><span class="p">(</span><span class="mi">200</span><span class="p">),</span>
<span class="na">sku</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">max</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
<span class="na">quantity</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">number</span><span class="p">().</span><span class="nf">int</span><span class="p">().</span><span class="nf">positive</span><span class="p">(),</span>
<span class="na">event_at</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">datetime</span><span class="p">({</span> <span class="na">offset</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}),</span>
<span class="p">});</span>
<span class="kd">const</span> <span class="nx">batchSchema</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nf">object</span><span class="p">({</span>
<span class="na">tenantId</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">regex</span><span class="p">(</span><span class="sr">/^</span><span class="se">[</span><span class="sr">a-z0-9-</span><span class="se">]{1,64}</span><span class="sr">$/</span><span class="p">),</span>
<span class="na">batchId</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">max</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
<span class="na">fileName</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">endsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">.csv</span><span class="dl">"</span><span class="p">),</span>
<span class="na">sha256</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">string</span><span class="p">().</span><span class="nf">regex</span><span class="p">(</span><span class="sr">/^</span><span class="se">[</span><span class="sr">a-f0-9</span><span class="se">]{64}</span><span class="sr">$/</span><span class="p">),</span>
<span class="na">rows</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nf">array</span><span class="p">(</span><span class="nx">rowSchema</span><span class="p">).</span><span class="nf">min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">max</span><span class="p">(</span><span class="mi">10</span><span class="nx">_000</span><span class="p">),</span>
<span class="p">});</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="dl">"</span><span class="s2">/v1/batches</span><span class="dl">"</span><span class="p">,</span> <span class="k">async </span><span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="nx">reply</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">parsed</span> <span class="o">=</span> <span class="nx">batchSchema</span><span class="p">.</span><span class="nf">safeParse</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">body</span><span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">parsed</span><span class="p">.</span><span class="nx">success</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nf">code</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nf">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Invalid batch payload</span><span class="dl">"</span> <span class="p">});</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">input</span> <span class="o">=</span> <span class="nx">parsed</span><span class="p">.</span><span class="nx">data</span><span class="p">;</span>
<span class="c1">// Gateway auth must bind this tenantId to the authenticated caller.</span>
<span class="kd">const</span> <span class="nx">byOrder</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Map</span><span class="o"><</span><span class="kr">string</span><span class="p">,</span> <span class="k">typeof</span> <span class="nx">input</span><span class="p">.</span><span class="nx">rows</span><span class="o">></span><span class="p">();</span>
<span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">row</span> <span class="k">of</span> <span class="nx">input</span><span class="p">.</span><span class="nx">rows</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">group</span> <span class="o">=</span> <span class="nx">byOrder</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="nx">row</span><span class="p">.</span><span class="nx">order_id</span><span class="p">)</span> <span class="o">??</span> <span class="p">[];</span>
<span class="nx">group</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="nx">row</span><span class="p">);</span>
<span class="nx">byOrder</span><span class="p">.</span><span class="nf">set</span><span class="p">(</span><span class="nx">row</span><span class="p">.</span><span class="nx">order_id</span><span class="p">,</span> <span class="nx">group</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">rows</span> <span class="k">of</span> <span class="nx">byOrder</span><span class="p">.</span><span class="nf">values</span><span class="p">())</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">first</span> <span class="o">=</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
<span class="kd">const</span> <span class="nx">skus</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="o"><</span><span class="kr">string</span><span class="o">></span><span class="p">();</span>
<span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">row</span> <span class="k">of</span> <span class="nx">rows</span><span class="p">)</span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">row</span><span class="p">.</span><span class="nx">customer_id</span> <span class="o">!==</span> <span class="nx">first</span><span class="p">.</span><span class="nx">customer_id</span> <span class="o">||</span>
<span class="nx">row</span><span class="p">.</span><span class="nx">customer_name</span> <span class="o">!==</span> <span class="nx">first</span><span class="p">.</span><span class="nx">customer_name</span> <span class="o">||</span>
<span class="nx">row</span><span class="p">.</span><span class="nx">event_at</span> <span class="o">!==</span> <span class="nx">first</span><span class="p">.</span><span class="nx">event_at</span> <span class="o">||</span> <span class="nx">skus</span><span class="p">.</span><span class="nf">has</span><span class="p">(</span><span class="nx">row</span><span class="p">.</span><span class="nx">sku</span><span class="p">))</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nf">code</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nf">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Inconsistent order snapshot</span><span class="dl">"</span> <span class="p">});</span>
<span class="p">}</span>
<span class="nx">skus</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="nx">row</span><span class="p">.</span><span class="nx">sku</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">client</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">connect</span><span class="p">();</span>
<span class="k">try</span> <span class="p">{</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="dl">"</span><span class="s2">BEGIN</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">inserted</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`INSERT INTO ingestion_batches (tenant_id, batch_id, sha256)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING batch_id`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">input</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">input</span><span class="p">.</span><span class="nx">batchId</span><span class="p">,</span> <span class="nx">input</span><span class="p">.</span><span class="nx">sha256</span><span class="p">],</span>
<span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">inserted</span><span class="p">.</span><span class="nx">rowCount</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">existing</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`SELECT sha256 FROM ingestion_batches
WHERE tenant_id = $1 AND batch_id = $2`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">input</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">input</span><span class="p">.</span><span class="nx">batchId</span><span class="p">],</span>
<span class="p">);</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="dl">"</span><span class="s2">COMMIT</span><span class="dl">"</span><span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">existing</span><span class="p">.</span><span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">]?.</span><span class="nx">sha256</span> <span class="o">!==</span> <span class="nx">input</span><span class="p">.</span><span class="nx">sha256</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nf">code</span><span class="p">(</span><span class="mi">409</span><span class="p">).</span><span class="nf">send</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Batch ID reused with new bytes</span><span class="dl">"</span> <span class="p">});</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nf">send</span><span class="p">({</span> <span class="na">status</span><span class="p">:</span> <span class="dl">"</span><span class="s2">already-committed</span><span class="dl">"</span> <span class="p">});</span>
<span class="p">}</span>
<span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="p">[</span><span class="nx">orderId</span><span class="p">,</span> <span class="nx">rows</span><span class="p">]</span> <span class="k">of</span> <span class="nx">byOrder</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">first</span> <span class="o">=</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`INSERT INTO customers (tenant_id, customer_id, customer_name)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, customer_id)
DO UPDATE SET customer_name = EXCLUDED.customer_name`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">input</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">first</span><span class="p">.</span><span class="nx">customer_id</span><span class="p">,</span> <span class="nx">first</span><span class="p">.</span><span class="nx">customer_name</span><span class="p">],</span>
<span class="p">);</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`INSERT INTO orders (tenant_id, order_id, customer_id, event_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, order_id) DO UPDATE SET
customer_id = EXCLUDED.customer_id,
event_at = EXCLUDED.event_at
WHERE orders.event_at <= EXCLUDED.event_at`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">input</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">orderId</span><span class="p">,</span> <span class="nx">first</span><span class="p">.</span><span class="nx">customer_id</span><span class="p">,</span> <span class="nx">first</span><span class="p">.</span><span class="nx">event_at</span><span class="p">],</span>
<span class="p">);</span>
<span class="kd">const</span> <span class="nx">current</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`SELECT event_at FROM orders WHERE tenant_id = $1 AND order_id = $2
FOR UPDATE`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">input</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">orderId</span><span class="p">],</span>
<span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="k">new</span> <span class="nc">Date</span><span class="p">(</span><span class="nx">current</span><span class="p">.</span><span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">event_at</span><span class="p">).</span><span class="nf">toISOString</span><span class="p">()</span> <span class="o">!==</span>
<span class="k">new</span> <span class="nc">Date</span><span class="p">(</span><span class="nx">first</span><span class="p">.</span><span class="nx">event_at</span><span class="p">).</span><span class="nf">toISOString</span><span class="p">())</span> <span class="k">continue</span><span class="p">;</span> <span class="c1">// stale snapshot</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`DELETE FROM order_items WHERE tenant_id = $1 AND order_id = $2`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">input</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">orderId</span><span class="p">],</span>
<span class="p">);</span>
<span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">row</span> <span class="k">of</span> <span class="nx">rows</span><span class="p">)</span> <span class="p">{</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`INSERT INTO order_items (tenant_id, order_id, sku, quantity)
VALUES ($1, $2, $3, $4)`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">input</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">orderId</span><span class="p">,</span> <span class="nx">row</span><span class="p">.</span><span class="nx">sku</span><span class="p">,</span> <span class="nx">row</span><span class="p">.</span><span class="nx">quantity</span><span class="p">],</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="dl">"</span><span class="s2">COMMIT</span><span class="dl">"</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nf">send</span><span class="p">({</span> <span class="na">status</span><span class="p">:</span> <span class="dl">"</span><span class="s2">committed</span><span class="dl">"</span><span class="p">,</span> <span class="na">orders</span><span class="p">:</span> <span class="nx">byOrder</span><span class="p">.</span><span class="nx">size</span> <span class="p">});</span>
<span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
<span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="dl">"</span><span class="s2">ROLLBACK</span><span class="dl">"</span><span class="p">);</span>
<span class="k">throw</span> <span class="nx">error</span><span class="p">;</span>
<span class="p">}</span> <span class="k">finally</span> <span class="p">{</span>
<span class="nx">client</span><span class="p">.</span><span class="nf">release</span><span class="p">();</span>
<span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>
<p>Notice the batch marker is inserted inside the same transaction as the rows. If the transaction fails, the marker disappears too. Retrying the file then does useful work instead of being mistaken for a success. The <code class="language-plaintext highlighter-rouge">FOR UPDATE</code> also serializes replacement of one order’s items. For exact ties in <code class="language-plaintext highlighter-rouge">event_at</code>, the upstream contract should supply a monotonically increasing revision; timestamps alone cannot tell which different snapshot wins.</p>
<p>The read half of the data API keeps PostgreSQL behind the boundary too:</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="kd">get</span><span class="o"><</span><span class="p">{</span> <span class="na">Params</span><span class="p">:</span> <span class="p">{</span> <span class="na">orderId</span><span class="p">:</span> <span class="kr">string</span> <span class="p">}</span> <span class="p">}</span><span class="o">></span><span class="p">(</span>
<span class="dl">"</span><span class="s2">/v1/orders/:orderId</span><span class="dl">"</span><span class="p">,</span>
<span class="k">async </span><span class="p">(</span><span class="nx">request</span><span class="p">,</span> <span class="nx">reply</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="c1">// tenantId comes from authenticated gateway context, never a query param.</span>
<span class="kd">const</span> <span class="nx">tenantId</span> <span class="o">=</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">[</span><span class="dl">"</span><span class="s2">x-verified-tenant-id</span><span class="dl">"</span><span class="p">]</span> <span class="kd">as </span><span class="kr">string</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">order</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`SELECT o.order_id, o.event_at, c.customer_id, c.customer_name
FROM orders o JOIN customers c
ON c.tenant_id = o.tenant_id AND c.customer_id = o.customer_id
WHERE o.tenant_id = $1 AND o.order_id = $2`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">orderId</span><span class="p">],</span>
<span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">order</span><span class="p">.</span><span class="nx">rowCount</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="nx">reply</span><span class="p">.</span><span class="nf">code</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="nf">send</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">pool</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
<span class="s2">`SELECT sku, quantity FROM order_items
WHERE tenant_id = $1 AND order_id = $2 ORDER BY sku`</span><span class="p">,</span>
<span class="p">[</span><span class="nx">tenantId</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">orderId</span><span class="p">],</span>
<span class="p">);</span>
<span class="k">return</span> <span class="p">{</span> <span class="p">...</span><span class="nx">order</span><span class="p">.</span><span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="na">items</span><span class="p">:</span> <span class="nx">items</span><span class="p">.</span><span class="nx">rows</span> <span class="p">};</span>
<span class="p">},</span>
<span class="p">);</span>
</code></pre></div></div>
<p>That <code class="language-plaintext highlighter-rouge">x-verified-tenant-id</code> header is only safe if a trusted gateway strips any client-supplied copy and injects its own value. In a direct deployment, put authentication and tenant extraction in a Fastify hook instead. The point is to show where tenant ownership is enforced, because “the new API is private” is not an authorization strategy.</p>
<p>The consumer needs the same route decision. Here the Databricks query uses a named parameter and the PostgreSQL side uses the data API. The adapter presents one order shape to its caller. This example assumes the order ID is globally unique in Databricks; if it is only unique per tenant, add <code class="language-plaintext highlighter-rouge">tenant_id</code> to the Delta tables and both predicates.</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pipeline/read-order.ts</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">OrderView</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">orderId</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">customerId</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">customerName</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">eventAt</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">items</span><span class="p">:</span> <span class="p">{</span> <span class="na">sku</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">quantity</span><span class="p">:</span> <span class="kr">number</span> <span class="p">}[];</span>
<span class="p">};</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">getOrder</span><span class="p">(</span>
<span class="nx">config</span><span class="p">:</span> <span class="nx">RouteSnapshot</span><span class="p">,</span>
<span class="nx">tenantId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="nx">orderId</span><span class="p">:</span> <span class="kr">string</span><span class="p">,</span>
<span class="nx">deps</span><span class="p">:</span> <span class="p">{</span> <span class="nl">databricks</span><span class="p">:</span> <span class="nx">DatabricksSql</span><span class="p">;</span> <span class="nl">apiBase</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">serviceToken</span><span class="p">:</span> <span class="kr">string</span> <span class="p">},</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o"><</span><span class="nx">OrderView</span> <span class="o">|</span> <span class="kc">null</span><span class="o">></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nf">choosePath</span><span class="p">(</span><span class="nx">config</span><span class="p">,</span> <span class="nx">tenantId</span><span class="p">).</span><span class="nx">path</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">postgres</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span>
<span class="s2">`</span><span class="p">${</span><span class="nx">deps</span><span class="p">.</span><span class="nx">apiBase</span><span class="p">}</span><span class="s2">/v1/orders/</span><span class="p">${</span><span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">orderId</span><span class="p">)}</span><span class="s2">`</span><span class="p">,</span>
<span class="p">{</span> <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="na">Authorization</span><span class="p">:</span> <span class="s2">`Bearer </span><span class="p">${</span><span class="nx">deps</span><span class="p">.</span><span class="nx">serviceToken</span><span class="p">}</span><span class="s2">`</span> <span class="p">}</span> <span class="p">},</span>
<span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="mi">404</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="s2">`Data API HTTP </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">row</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">()</span> <span class="kd">as </span><span class="p">{</span>
<span class="na">order_id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">customer_id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">customer_name</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">event_at</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">items</span><span class="p">:</span> <span class="p">{</span> <span class="na">sku</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">quantity</span><span class="p">:</span> <span class="kr">number</span> <span class="p">}[];</span>
<span class="p">};</span>
<span class="k">return</span> <span class="p">{</span>
<span class="na">orderId</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">order_id</span><span class="p">,</span> <span class="na">customerId</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">customer_id</span><span class="p">,</span>
<span class="na">customerName</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">customer_name</span><span class="p">,</span> <span class="na">eventAt</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">event_at</span><span class="p">,</span>
<span class="na">items</span><span class="p">:</span> <span class="nx">row</span><span class="p">.</span><span class="nx">items</span><span class="p">,</span>
<span class="p">};</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">rows</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">deps</span><span class="p">.</span><span class="nx">databricks</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span><span class="s2">`
SELECT order_id, customer_id, customer_name, sku,
CAST(quantity AS STRING), CAST(event_at AS STRING)
FROM main.orders.order_rows_current WHERE order_id = :order_id
ORDER BY sku
`</span><span class="p">,</span> <span class="p">[{</span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">order_id</span><span class="dl">"</span><span class="p">,</span> <span class="na">value</span><span class="p">:</span> <span class="nx">orderId</span> <span class="p">}]);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">rows</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="k">return</span> <span class="p">{</span>
<span class="na">orderId</span><span class="p">:</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">0</span><span class="p">],</span> <span class="na">customerId</span><span class="p">:</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">1</span><span class="p">],</span>
<span class="na">customerName</span><span class="p">:</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">2</span><span class="p">],</span> <span class="na">eventAt</span><span class="p">:</span> <span class="nx">rows</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">5</span><span class="p">],</span>
<span class="na">items</span><span class="p">:</span> <span class="nx">rows</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="nx">row</span> <span class="o">=></span> <span class="p">({</span> <span class="na">sku</span><span class="p">:</span> <span class="nx">row</span><span class="p">[</span><span class="mi">3</span><span class="p">],</span> <span class="na">quantity</span><span class="p">:</span> <span class="nc">Number</span><span class="p">(</span><span class="nx">row</span><span class="p">[</span><span class="mi">4</span><span class="p">])</span> <span class="p">})),</span>
<span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>
<p>Finally, the worker uses its pinned route. Read traffic should use the tenant route only after the PostgreSQL side has been backfilled and checked.</p>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// pipeline/worker.ts</span>
<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nf">processBatch</span><span class="p">(</span>
<span class="nx">config</span><span class="p">:</span> <span class="nx">RouteSnapshot</span><span class="p">,</span>
<span class="nx">batch</span><span class="p">:</span> <span class="nx">BatchRef</span><span class="p">,</span>
<span class="nx">deps</span><span class="p">:</span> <span class="p">{</span> <span class="nl">databricks</span><span class="p">:</span> <span class="nx">DatabricksSql</span><span class="p">;</span> <span class="nl">apiBase</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">serviceToken</span><span class="p">:</span> <span class="kr">string</span> <span class="p">},</span>
<span class="p">)</span> <span class="p">{</span>
<span class="nf">checkBatchRef</span><span class="p">(</span><span class="nx">batch</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">decision</span> <span class="o">=</span> <span class="nf">choosePath</span><span class="p">(</span><span class="nx">config</span><span class="p">,</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">tenantId</span><span class="p">);</span>
<span class="c1">// Persist {batchId, sha256, path, configVersion} in your job ledger.</span>
<span class="k">if </span><span class="p">(</span><span class="nx">decision</span><span class="p">.</span><span class="nx">path</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">databricks</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
<span class="k">await</span> <span class="nf">loadDatabricksBatch</span><span class="p">(</span><span class="nx">deps</span><span class="p">.</span><span class="nx">databricks</span><span class="p">,</span> <span class="nx">batch</span><span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="k">await</span> <span class="nf">loadPostgresBatch</span><span class="p">(</span><span class="nx">batch</span><span class="p">,</span> <span class="nx">deps</span><span class="p">.</span><span class="nx">apiBase</span><span class="p">,</span> <span class="nx">deps</span><span class="p">.</span><span class="nx">serviceToken</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">decision</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>
<p>My cutover order would be: add the new API and schema, backfill it from immutable files, compare order counts and sampled contents, run both paths in a controlled shadow period, then route one tenant’s <strong>writes and reads</strong> to PostgreSQL. Measure lag, API errors, rejected rows, and mismatches by tenant. Keep the original feed while rollback is needed. If writes have happened only on the new path, flipping the read switch back to Databricks can show stale data; rollback needs replay to the old path or a freeze until it catches up. That’s the part the tidy <code class="language-plaintext highlighter-rouge">if</code> statement never tells you.</p>
<h2 id="2-expedited-shipping-a-feature-flag-with-a-bill-attached">2. Expedited shipping: a feature flag with a bill attached</h2>
<p>Here’s a second example: turn on an expedited-shipping offer for a percentage of accounts. The new path does not merely paint a badge. It changes the checkout quote, so we need one stable decision per order and a record of the rule that produced the price.</p>
<p>The rule is: the account is included in the rollout, the destination is in the supported region, the cart subtotal is at least 7,500 cents, and the feature has not been killed globally. I use a stable FNV-1a hash of <code class="language-plaintext highlighter-rouge">accountId</code> for the rollout. It is small and portable across TypeScript and Go; it is <strong>not</strong> a security primitive. A production flag service can own the assignment instead, provided both stacks read the same assignment.</p>
<h3 id="typescript-implementation">TypeScript implementation</h3>
<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// checkout/expedited.ts</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">ShippingFlag</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">enabled</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">;</span>
<span class="nl">killSwitch</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">;</span>
<span class="nl">rolloutPercent</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span> <span class="c1">// integer 0..100</span>
<span class="nl">revision</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">};</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">QuoteInput</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">accountId</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">subtotalCents</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
<span class="nl">region</span><span class="p">:</span> <span class="dl">"</span><span class="s2">US-LOWER-48</span><span class="dl">"</span> <span class="o">|</span> <span class="dl">"</span><span class="s2">US-OTHER</span><span class="dl">"</span> <span class="o">|</span> <span class="dl">"</span><span class="s2">INTERNATIONAL</span><span class="dl">"</span><span class="p">;</span>
<span class="p">};</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">ShippingQuote</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">standardCents</span><span class="p">:</span> <span class="kr">number</span><span class="p">;</span>
<span class="nl">expeditedCents</span><span class="p">:</span> <span class="kr">number</span> <span class="o">|</span> <span class="kc">null</span><span class="p">;</span>
<span class="nl">decision</span><span class="p">:</span> <span class="p">{</span>
<span class="na">offered</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">;</span>
<span class="nl">flagRevision</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">reason</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">};</span>
<span class="p">};</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">bucket</span><span class="p">(</span><span class="nx">accountId</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="kr">number</span> <span class="p">{</span>
<span class="kd">let</span> <span class="nx">hash</span> <span class="o">=</span> <span class="mh">0x811c9dc5</span><span class="p">;</span>
<span class="k">for </span><span class="p">(</span><span class="kd">const</span> <span class="nx">byte</span> <span class="k">of</span> <span class="k">new</span> <span class="nc">TextEncoder</span><span class="p">().</span><span class="nf">encode</span><span class="p">(</span><span class="nx">accountId</span><span class="p">))</span> <span class="p">{</span>
<span class="nx">hash</span> <span class="o">^=</span> <span class="nx">byte</span><span class="p">;</span>
<span class="nx">hash</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">imul</span><span class="p">(</span><span class="nx">hash</span><span class="p">,</span> <span class="mh">0x01000193</span><span class="p">)</span> <span class="o">>>></span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">hash</span> <span class="o">%</span> <span class="mi">100</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">quoteShipping</span><span class="p">(</span><span class="nx">input</span><span class="p">:</span> <span class="nx">QuoteInput</span><span class="p">,</span> <span class="nx">flag</span><span class="p">:</span> <span class="nx">ShippingFlag</span><span class="p">):</span> <span class="nx">ShippingQuote</span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nb">Number</span><span class="p">.</span><span class="nf">isSafeInteger</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">subtotalCents</span><span class="p">)</span> <span class="o">||</span> <span class="nx">input</span><span class="p">.</span><span class="nx">subtotalCents</span> <span class="o"><</span> <span class="mi">0</span> <span class="o">||</span>
<span class="o">!</span><span class="nb">Number</span><span class="p">.</span><span class="nf">isInteger</span><span class="p">(</span><span class="nx">flag</span><span class="p">.</span><span class="nx">rolloutPercent</span><span class="p">)</span> <span class="o">||</span>
<span class="nx">flag</span><span class="p">.</span><span class="nx">rolloutPercent</span> <span class="o"><</span> <span class="mi">0</span> <span class="o">||</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">rolloutPercent</span> <span class="o">></span> <span class="mi">100</span><span class="p">)</span> <span class="p">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Invalid quote input or flag configuration</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">reason</span> <span class="o">=</span> <span class="o">!</span><span class="nx">flag</span><span class="p">.</span><span class="nx">enabled</span> <span class="o">||</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">killSwitch</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">disabled</span><span class="dl">"</span>
<span class="p">:</span> <span class="nf">bucket</span><span class="p">(</span><span class="nx">input</span><span class="p">.</span><span class="nx">accountId</span><span class="p">)</span> <span class="o">>=</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">rolloutPercent</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">outside-rollout</span><span class="dl">"</span>
<span class="p">:</span> <span class="nx">input</span><span class="p">.</span><span class="nx">region</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">US-LOWER-48</span><span class="dl">"</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">unsupported-region</span><span class="dl">"</span>
<span class="p">:</span> <span class="nx">input</span><span class="p">.</span><span class="nx">subtotalCents</span> <span class="o"><</span> <span class="mi">7</span><span class="nx">_500</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">below-minimum</span><span class="dl">"</span>
<span class="p">:</span> <span class="dl">"</span><span class="s2">eligible</span><span class="dl">"</span><span class="p">;</span>
<span class="k">return</span> <span class="p">{</span>
<span class="na">standardCents</span><span class="p">:</span> <span class="mi">799</span><span class="p">,</span>
<span class="na">expeditedCents</span><span class="p">:</span> <span class="nx">reason</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">eligible</span><span class="dl">"</span> <span class="p">?</span> <span class="mi">1499</span> <span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="na">decision</span><span class="p">:</span> <span class="p">{</span>
<span class="na">offered</span><span class="p">:</span> <span class="nx">reason</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">eligible</span><span class="dl">"</span><span class="p">,</span>
<span class="na">flagRevision</span><span class="p">:</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">revision</span><span class="p">,</span>
<span class="nx">reason</span><span class="p">,</span>
<span class="p">},</span>
<span class="p">};</span>
<span class="p">}</span>
<span class="c1">// At checkout creation, persist the quoted cents and decision with the order.</span>
<span class="c1">// At payment confirmation, charge that stored quote after its normal expiry</span>
<span class="c1">// and inventory checks. Do not ask the flag again halfway through checkout.</span>
</code></pre></div></div>
<p>This is where teams often reach for <code class="language-plaintext highlighter-rouge">Math.random()</code> and accidentally give the same customer a different offer on every refresh. The bucket makes the rollout stable. The saved decision makes an in-progress checkout stable even if the operator moves from 10% to 20% while a customer is entering their card details.</p>
<h3 id="the-same-boundary-in-go">The same boundary in Go</h3>
<p>If another service computes a quote in Go, it must agree on byte encoding, hash, threshold, region names, and cents. This uses only the standard library.</p>
<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">package</span> <span class="n">shipping</span>
<span class="k">import</span> <span class="p">(</span>
<span class="s">"errors"</span>
<span class="s">"hash/fnv"</span>
<span class="p">)</span>
<span class="k">type</span> <span class="n">Flag</span> <span class="k">struct</span> <span class="p">{</span>
<span class="n">Enabled</span> <span class="kt">bool</span>
<span class="n">KillSwitch</span> <span class="kt">bool</span>
<span class="n">RolloutPercent</span> <span class="kt">int</span>
<span class="n">Revision</span> <span class="kt">string</span>
<span class="p">}</span>
<span class="k">type</span> <span class="n">Input</span> <span class="k">struct</span> <span class="p">{</span>
<span class="n">AccountID</span> <span class="kt">string</span>
<span class="n">SubtotalCents</span> <span class="kt">int64</span>
<span class="n">Region</span> <span class="kt">string</span>
<span class="p">}</span>
<span class="k">type</span> <span class="n">Decision</span> <span class="k">struct</span> <span class="p">{</span>
<span class="n">Offered</span> <span class="kt">bool</span>
<span class="n">FlagRevision</span> <span class="kt">string</span>
<span class="n">Reason</span> <span class="kt">string</span>
<span class="p">}</span>
<span class="k">type</span> <span class="n">Quote</span> <span class="k">struct</span> <span class="p">{</span>
<span class="n">StandardCents</span> <span class="kt">int64</span>
<span class="n">ExpeditedCents</span> <span class="o">*</span><span class="kt">int64</span>
<span class="n">Decision</span> <span class="n">Decision</span>
<span class="p">}</span>
<span class="k">func</span> <span class="n">Bucket</span><span class="p">(</span><span class="n">accountID</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
<span class="n">h</span> <span class="o">:=</span> <span class="n">fnv</span><span class="o">.</span><span class="n">New32a</span><span class="p">()</span>
<span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">h</span><span class="o">.</span><span class="n">Write</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="n">accountID</span><span class="p">))</span> <span class="c">// UTF-8 bytes, as in TextEncoder</span>
<span class="k">return</span> <span class="kt">int</span><span class="p">(</span><span class="n">h</span><span class="o">.</span><span class="n">Sum32</span><span class="p">()</span> <span class="o">%</span> <span class="m">100</span><span class="p">)</span>
<span class="p">}</span>
<span class="k">func</span> <span class="n">QuoteShipping</span><span class="p">(</span><span class="n">in</span> <span class="n">Input</span><span class="p">,</span> <span class="n">flag</span> <span class="n">Flag</span><span class="p">)</span> <span class="p">(</span><span class="n">Quote</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
<span class="k">if</span> <span class="n">in</span><span class="o">.</span><span class="n">SubtotalCents</span> <span class="o"><</span> <span class="m">0</span> <span class="o">||</span> <span class="n">flag</span><span class="o">.</span><span class="n">RolloutPercent</span> <span class="o"><</span> <span class="m">0</span> <span class="o">||</span>
<span class="n">flag</span><span class="o">.</span><span class="n">RolloutPercent</span> <span class="o">></span> <span class="m">100</span> <span class="p">{</span>
<span class="k">return</span> <span class="n">Quote</span><span class="p">{},</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"invalid quote input or flag configuration"</span><span class="p">)</span>
<span class="p">}</span>
<span class="n">reason</span> <span class="o">:=</span> <span class="s">"eligible"</span>
<span class="k">switch</span> <span class="p">{</span>
<span class="k">case</span> <span class="o">!</span><span class="n">flag</span><span class="o">.</span><span class="n">Enabled</span> <span class="o">||</span> <span class="n">flag</span><span class="o">.</span><span class="n">KillSwitch</span><span class="o">:</span>
<span class="n">reason</span> <span class="o">=</span> <span class="s">"disabled"</span>
<span class="k">case</span> <span class="n">Bucket</span><span class="p">(</span><span class="n">in</span><span class="o">.</span><span class="n">AccountID</span><span class="p">)</span> <span class="o">>=</span> <span class="n">flag</span><span class="o">.</span><span class="n">RolloutPercent</span><span class="o">:</span>
<span class="n">reason</span> <span class="o">=</span> <span class="s">"outside-rollout"</span>
<span class="k">case</span> <span class="n">in</span><span class="o">.</span><span class="n">Region</span> <span class="o">!=</span> <span class="s">"US-LOWER-48"</span><span class="o">:</span>
<span class="n">reason</span> <span class="o">=</span> <span class="s">"unsupported-region"</span>
<span class="k">case</span> <span class="n">in</span><span class="o">.</span><span class="n">SubtotalCents</span> <span class="o"><</span> <span class="m">7500</span><span class="o">:</span>
<span class="n">reason</span> <span class="o">=</span> <span class="s">"below-minimum"</span>
<span class="p">}</span>
<span class="n">result</span> <span class="o">:=</span> <span class="n">Quote</span><span class="p">{</span>
<span class="n">StandardCents</span><span class="o">:</span> <span class="m">799</span><span class="p">,</span>
<span class="n">Decision</span><span class="o">:</span> <span class="n">Decision</span><span class="p">{</span>
<span class="n">Offered</span><span class="o">:</span> <span class="n">reason</span> <span class="o">==</span> <span class="s">"eligible"</span><span class="p">,</span> <span class="n">FlagRevision</span><span class="o">:</span> <span class="n">flag</span><span class="o">.</span><span class="n">Revision</span><span class="p">,</span>
<span class="n">Reason</span><span class="o">:</span> <span class="n">reason</span><span class="p">,</span>
<span class="p">},</span>
<span class="p">}</span>
<span class="k">if</span> <span class="n">result</span><span class="o">.</span><span class="n">Decision</span><span class="o">.</span><span class="n">Offered</span> <span class="p">{</span>
<span class="n">expedited</span> <span class="o">:=</span> <span class="kt">int64</span><span class="p">(</span><span class="m">1499</span><span class="p">)</span>
<span class="n">result</span><span class="o">.</span><span class="n">ExpeditedCents</span> <span class="o">=</span> <span class="o">&</span><span class="n">expedited</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">result</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>
<p>I would put a shared set of fixture inputs in both test suites: account IDs with ASCII and non-ASCII characters, rollout at 0 and 100, subtotal at 7,499 and 7,500 cents, each region, and the kill switch. The tests should assert that TypeScript and Go assign the same account to the same bucket. This is one of those boring cross-language details that becomes a very exciting checkout bug if you skip it.</p>
<p>The rollout sequence is straightforward: ship both implementations dark, compare decisions against fixtures, enable internal accounts, then 1%, 10%, and onward while watching quote errors, conversion, fulfillment capacity, and customer support reports. The kill switch stops <strong>new offers</strong>. Existing orders retain their stored price and promise; changing those would be a different business operation. When the feature is permanent, remove the rollout branch and leave the eligibility rule as normal checkout code.</p>
<h2 id="3-saved-filters-the-interface-switch-and-the-users-own-toggle">3. Saved filters: the interface switch and the user’s own toggle</h2>
<p>The third example is a saved-filters panel for an order list. There are two switches here that people tend to conflate. The <strong>feature flag</strong> says whether this account has access to saved filters. The <strong>user preference</strong> says whether the available panel is currently shown. If the feature flag is off, the preference cannot manufacture access.</p>
<p>The server returns a capability document after authentication:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
</span><span class="nl">"savedFilters"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
</span><span class="nl">"revision"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ui-2026-09-24-3"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>The endpoint that creates or lists saved filters must evaluate the account’s capability again. A React component that omits a button is a nicer screen, not an access control check. The same applies on iOS.</p>
<h3 id="typescript-and-react">TypeScript and React</h3>
<p>The browser fetches the capability, treats unknown as off, and lets the person hide or show the panel with a visible toggle. The preference is local to the browser in this version; if we want it to follow a user across devices, that becomes a server-side preference with its own API and migration.</p>
<div class="language-tsx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// SavedFiltersFeature.tsx</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">useEffect</span><span class="p">,</span> <span class="nx">useState</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">react</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">type</span> <span class="nx">Capabilities</span> <span class="o">=</span> <span class="p">{</span> <span class="na">savedFilters</span><span class="p">:</span> <span class="nx">boolean</span><span class="p">;</span> <span class="nl">revision</span><span class="p">:</span> <span class="kr">string</span> <span class="p">};</span>
<span class="kd">type</span> <span class="nx">Filter</span> <span class="o">=</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">name</span><span class="p">:</span> <span class="kr">string</span><span class="p">;</span> <span class="nl">query</span><span class="p">:</span> <span class="kr">string</span> <span class="p">};</span>
<span class="k">export</span> <span class="kd">function</span> <span class="nf">SavedFiltersFeature</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">[</span><span class="nx">capability</span><span class="p">,</span> <span class="nx">setCapability</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="o"><</span><span class="nx">Capabilities</span> <span class="o">|</span> <span class="kc">null</span><span class="o">></span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">[</span><span class="nx">filters</span><span class="p">,</span> <span class="nx">setFilters</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="o"><</span><span class="nx">Filter</span><span class="p">[]</span><span class="o">></span><span class="p">([]);</span>
<span class="kd">const</span> <span class="p">[</span><span class="nx">open</span><span class="p">,</span> <span class="nx">setOpen</span><span class="p">]</span> <span class="o">=</span> <span class="nf">useState</span><span class="p">(</span>
<span class="p">()</span> <span class="o">=></span> <span class="nx">localStorage</span><span class="p">.</span><span class="nf">getItem</span><span class="p">(</span><span class="dl">"</span><span class="s2">saved-filters-open</span><span class="dl">"</span><span class="p">)</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span><span class="p">,</span>
<span class="p">);</span>
<span class="kd">const</span> <span class="p">[</span><span class="nx">error</span><span class="p">,</span> <span class="nx">setError</span><span class="p">]</span> <span class="o">=</span> <span class="nx">useState</span><span class="o"><</span><span class="kr">string</span> <span class="o">|</span> <span class="kc">null</span><span class="o">></span><span class="p">(</span><span class="kc">null</span><span class="p">);</span>
<span class="nf">useEffect</span><span class="p">(()</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">controller</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AbortController</span><span class="p">();</span>
<span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/v1/me/features</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">signal</span><span class="p">:</span> <span class="nx">controller</span><span class="p">.</span><span class="nx">signal</span><span class="p">,</span> <span class="na">credentials</span><span class="p">:</span> <span class="dl">"</span><span class="s2">include</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Could not load features</span><span class="dl">"</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">()</span> <span class="kd">as </span><span class="nb">Promise</span><span class="o"><</span><span class="nx">Capabilities</span><span class="o">></span><span class="p">;</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">setCapability</span><span class="p">)</span>
<span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">err</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">err</span><span class="p">.</span><span class="nx">name</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">AbortError</span><span class="dl">"</span><span class="p">)</span> <span class="nf">setError</span><span class="p">(</span><span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="p">);</span>
<span class="p">});</span>
<span class="k">return </span><span class="p">()</span> <span class="o">=></span> <span class="nx">controller</span><span class="p">.</span><span class="nf">abort</span><span class="p">();</span>
<span class="p">},</span> <span class="p">[]);</span>
<span class="nf">useEffect</span><span class="p">(()</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">capability</span><span class="p">?.</span><span class="nx">savedFilters</span> <span class="o">||</span> <span class="o">!</span><span class="nx">open</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">controller</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AbortController</span><span class="p">();</span>
<span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/v1/saved-filters</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">signal</span><span class="p">:</span> <span class="nx">controller</span><span class="p">.</span><span class="nx">signal</span><span class="p">,</span> <span class="na">credentials</span><span class="p">:</span> <span class="dl">"</span><span class="s2">include</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Could not load saved filters</span><span class="dl">"</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">()</span> <span class="kd">as </span><span class="nb">Promise</span><span class="o"><</span><span class="nx">Filter</span><span class="p">[]</span><span class="o">></span><span class="p">;</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">setFilters</span><span class="p">)</span>
<span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">err</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">err</span><span class="p">.</span><span class="nx">name</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">AbortError</span><span class="dl">"</span><span class="p">)</span> <span class="nf">setError</span><span class="p">(</span><span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="p">);</span>
<span class="p">});</span>
<span class="k">return </span><span class="p">()</span> <span class="o">=></span> <span class="nx">controller</span><span class="p">.</span><span class="nf">abort</span><span class="p">();</span>
<span class="p">},</span> <span class="p">[</span><span class="nx">capability</span><span class="p">?.</span><span class="nx">savedFilters</span><span class="p">,</span> <span class="nx">open</span><span class="p">]);</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">capability</span><span class="p">?.</span><span class="nx">savedFilters</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">error</span> <span class="p">?</span> <span class="p"><</span><span class="nt">p</span> <span class="na">role</span><span class="p">=</span><span class="s">"alert"</span><span class="p">></span><span class="si">{</span><span class="nx">error</span><span class="si">}</span><span class="p"></</span><span class="nt">p</span><span class="p">></span> <span class="p">:</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">return</span> <span class="p"><</span><span class="nt">section</span> <span class="na">aria-label</span><span class="p">=</span><span class="s">"Saved filters"</span><span class="p">></span>
<span class="p"><</span><span class="nt">label</span><span class="p">></span>
<span class="p"><</span><span class="nt">input</span> <span class="na">type</span><span class="p">=</span><span class="s">"checkbox"</span> <span class="na">checked</span><span class="p">=</span><span class="si">{</span><span class="nx">open</span><span class="si">}</span> <span class="na">onChange</span><span class="p">=</span><span class="si">{</span><span class="nx">event</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">next</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">checked</span><span class="p">;</span>
<span class="nf">setOpen</span><span class="p">(</span><span class="nx">next</span><span class="p">);</span>
<span class="nx">localStorage</span><span class="p">.</span><span class="nf">setItem</span><span class="p">(</span><span class="dl">"</span><span class="s2">saved-filters-open</span><span class="dl">"</span><span class="p">,</span> <span class="nc">String</span><span class="p">(</span><span class="nx">next</span><span class="p">));</span>
<span class="p">}</span><span class="si">}</span> <span class="p">/></span>
Show saved filters
<span class="p"></</span><span class="nt">label</span><span class="p">></span>
<span class="si">{</span><span class="nx">error</span> <span class="o">&&</span> <span class="p"><</span><span class="nt">p</span> <span class="na">role</span><span class="p">=</span><span class="s">"alert"</span><span class="p">></span><span class="si">{</span><span class="nx">error</span><span class="si">}</span><span class="p"></</span><span class="nt">p</span><span class="p">></span><span class="si">}</span>
<span class="si">{</span><span class="nx">open</span> <span class="o">&&</span> <span class="p"><</span><span class="nt">ul</span><span class="p">></span><span class="si">{</span><span class="nx">filters</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="nx">filter</span> <span class="o">=></span>
<span class="p"><</span><span class="nt">li</span> <span class="na">key</span><span class="p">=</span><span class="si">{</span><span class="nx">filter</span><span class="p">.</span><span class="nx">id</span><span class="si">}</span><span class="p">><</span><span class="nt">button</span> <span class="na">type</span><span class="p">=</span><span class="s">"button"</span> <span class="na">onClick</span><span class="p">=</span><span class="si">{</span><span class="p">()</span> <span class="o">=></span> <span class="p">{</span>
<span class="c1">// The order list owns applying filter.query, after validating its DSL.</span>
<span class="nb">window</span><span class="p">.</span><span class="nf">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nc">CustomEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">apply-saved-filter</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
<span class="na">detail</span><span class="p">:</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">id</span> <span class="p">},</span>
<span class="p">}));</span>
<span class="p">}</span><span class="si">}</span><span class="p">></span><span class="si">{</span><span class="nx">filter</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="p"></</span><span class="nt">button</span><span class="p">></</span><span class="nt">li</span><span class="p">>,</span>
<span class="p">)</span><span class="si">}</span><span class="p"></</span><span class="nt">ul</span><span class="p">></span><span class="si">}</span>
<span class="p"></</span><span class="nt">section</span><span class="p">>;</span>
<span class="p">}</span>
</code></pre></div></div>
<p>The button passes a filter ID, not arbitrary SQL from storage. The order list asks its own API to apply that ID under the current account. If the flag goes off while this page is open, a fresh capability fetch on navigation or a short-lived cache will remove the panel; the API check shuts off server access immediately.</p>
<h3 id="the-same-feature-in-swiftui">The same feature in SwiftUI</h3>
<p>On iOS, <code class="language-plaintext highlighter-rouge">@AppStorage</code> is the user preference. The capability still comes from the server. A small model loads it and, only when allowed and opened, loads the filters.</p>
<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">SwiftUI</span>
<span class="kd">struct</span> <span class="kt">Capabilities</span><span class="p">:</span> <span class="kt">Decodable</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">savedFilters</span><span class="p">:</span> <span class="kt">Bool</span>
<span class="k">let</span> <span class="nv">revision</span><span class="p">:</span> <span class="kt">String</span>
<span class="p">}</span>
<span class="kd">struct</span> <span class="kt">SavedFilter</span><span class="p">:</span> <span class="kt">Decodable</span><span class="p">,</span> <span class="kt">Identifiable</span> <span class="p">{</span>
<span class="k">let</span> <span class="nv">id</span><span class="p">:</span> <span class="kt">String</span>
<span class="k">let</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span>
<span class="k">let</span> <span class="nv">query</span><span class="p">:</span> <span class="kt">String</span>
<span class="p">}</span>
<span class="kd">@MainActor</span>
<span class="kd">final</span> <span class="kd">class</span> <span class="kt">SavedFiltersModel</span><span class="p">:</span> <span class="kt">ObservableObject</span> <span class="p">{</span>
<span class="kd">@Published</span> <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">capability</span><span class="p">:</span> <span class="kt">Capabilities</span><span class="p">?</span>
<span class="kd">@Published</span> <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">filters</span><span class="p">:</span> <span class="p">[</span><span class="kt">SavedFilter</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
<span class="kd">@Published</span> <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">errorMessage</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
<span class="c1">// apiBase and URLSession are injected so previews/tests can use fixtures.</span>
<span class="kd">private</span> <span class="k">let</span> <span class="nv">apiBase</span><span class="p">:</span> <span class="kt">URL</span>
<span class="kd">private</span> <span class="k">let</span> <span class="nv">session</span><span class="p">:</span> <span class="kt">URLSession</span>
<span class="nf">init</span><span class="p">(</span><span class="nv">apiBase</span><span class="p">:</span> <span class="kt">URL</span><span class="p">,</span> <span class="nv">session</span><span class="p">:</span> <span class="kt">URLSession</span> <span class="o">=</span> <span class="o">.</span><span class="n">shared</span><span class="p">)</span> <span class="p">{</span>
<span class="k">self</span><span class="o">.</span><span class="n">apiBase</span> <span class="o">=</span> <span class="n">apiBase</span>
<span class="k">self</span><span class="o">.</span><span class="n">session</span> <span class="o">=</span> <span class="n">session</span>
<span class="p">}</span>
<span class="kd">func</span> <span class="nf">loadCapability</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
<span class="k">do</span> <span class="p">{</span>
<span class="k">let</span> <span class="p">(</span><span class="nv">data</span><span class="p">,</span> <span class="nv">response</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">session</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span>
<span class="nv">from</span><span class="p">:</span> <span class="n">apiBase</span><span class="o">.</span><span class="nf">appending</span><span class="p">(</span><span class="nv">path</span><span class="p">:</span> <span class="s">"v1/me/features"</span><span class="p">))</span>
<span class="nf">guard</span> <span class="p">(</span><span class="n">response</span> <span class="k">as?</span> <span class="kt">HTTPURLResponse</span><span class="p">)?</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">==</span> <span class="mi">200</span> <span class="k">else</span> <span class="p">{</span>
<span class="k">throw</span> <span class="kt">URLError</span><span class="p">(</span><span class="o">.</span><span class="n">badServerResponse</span><span class="p">)</span>
<span class="p">}</span>
<span class="n">capability</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">(</span><span class="kt">Capabilities</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
<span class="k">if</span> <span class="n">capability</span><span class="p">?</span><span class="o">.</span><span class="n">savedFilters</span> <span class="o">!=</span> <span class="kc">true</span> <span class="p">{</span> <span class="n">filters</span> <span class="o">=</span> <span class="p">[]</span> <span class="p">}</span>
<span class="n">errorMessage</span> <span class="o">=</span> <span class="kc">nil</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
<span class="n">capability</span> <span class="o">=</span> <span class="kc">nil</span> <span class="c1">// unknown is off</span>
<span class="n">filters</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">errorMessage</span> <span class="o">=</span> <span class="s">"Features could not be loaded."</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">func</span> <span class="nf">loadFilters</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
<span class="k">guard</span> <span class="n">capability</span><span class="p">?</span><span class="o">.</span><span class="n">savedFilters</span> <span class="o">==</span> <span class="kc">true</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
<span class="k">do</span> <span class="p">{</span>
<span class="k">let</span> <span class="p">(</span><span class="nv">data</span><span class="p">,</span> <span class="nv">response</span><span class="p">)</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">session</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span>
<span class="nv">from</span><span class="p">:</span> <span class="n">apiBase</span><span class="o">.</span><span class="nf">appending</span><span class="p">(</span><span class="nv">path</span><span class="p">:</span> <span class="s">"v1/saved-filters"</span><span class="p">))</span>
<span class="nf">guard</span> <span class="p">(</span><span class="n">response</span> <span class="k">as?</span> <span class="kt">HTTPURLResponse</span><span class="p">)?</span><span class="o">.</span><span class="n">statusCode</span> <span class="o">==</span> <span class="mi">200</span> <span class="k">else</span> <span class="p">{</span>
<span class="k">throw</span> <span class="kt">URLError</span><span class="p">(</span><span class="o">.</span><span class="n">badServerResponse</span><span class="p">)</span>
<span class="p">}</span>
<span class="n">filters</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">([</span><span class="kt">SavedFilter</span><span class="p">]</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
<span class="n">errorMessage</span> <span class="o">=</span> <span class="kc">nil</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
<span class="n">filters</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">errorMessage</span> <span class="o">=</span> <span class="s">"Saved filters could not be loaded."</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">struct</span> <span class="kt">SavedFiltersView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
<span class="kd">@StateObject</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">model</span><span class="p">:</span> <span class="kt">SavedFiltersModel</span>
<span class="kd">@AppStorage</span><span class="p">(</span><span class="s">"savedFiltersOpen"</span><span class="p">)</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">isOpen</span> <span class="o">=</span> <span class="kc">false</span>
<span class="k">let</span> <span class="nv">applyFilter</span><span class="p">:</span> <span class="p">(</span><span class="kt">String</span><span class="p">)</span> <span class="o">-></span> <span class="kt">Void</span>
<span class="nf">init</span><span class="p">(</span><span class="nv">apiBase</span><span class="p">:</span> <span class="kt">URL</span><span class="p">,</span> <span class="nv">applyFilter</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="p">(</span><span class="kt">String</span><span class="p">)</span> <span class="o">-></span> <span class="kt">Void</span><span class="p">)</span> <span class="p">{</span>
<span class="n">_model</span> <span class="o">=</span> <span class="kt">StateObject</span><span class="p">(</span><span class="nv">wrappedValue</span><span class="p">:</span> <span class="kt">SavedFiltersModel</span><span class="p">(</span><span class="nv">apiBase</span><span class="p">:</span> <span class="n">apiBase</span><span class="p">))</span>
<span class="k">self</span><span class="o">.</span><span class="n">applyFilter</span> <span class="o">=</span> <span class="n">applyFilter</span>
<span class="p">}</span>
<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
<span class="kt">Group</span> <span class="p">{</span>
<span class="k">if</span> <span class="n">model</span><span class="o">.</span><span class="n">capability</span><span class="p">?</span><span class="o">.</span><span class="n">savedFilters</span> <span class="o">==</span> <span class="kc">true</span> <span class="p">{</span>
<span class="kt">Section</span><span class="p">(</span><span class="s">"Saved filters"</span><span class="p">)</span> <span class="p">{</span>
<span class="kt">Toggle</span><span class="p">(</span><span class="s">"Show saved filters"</span><span class="p">,</span> <span class="nv">isOn</span><span class="p">:</span> <span class="n">$isOpen</span><span class="p">)</span>
<span class="k">if</span> <span class="n">isOpen</span> <span class="p">{</span>
<span class="kt">ForEach</span><span class="p">(</span><span class="n">model</span><span class="o">.</span><span class="n">filters</span><span class="p">)</span> <span class="p">{</span> <span class="n">filter</span> <span class="k">in</span>
<span class="kt">Button</span><span class="p">(</span><span class="n">filter</span><span class="o">.</span><span class="n">name</span><span class="p">)</span> <span class="p">{</span> <span class="nf">applyFilter</span><span class="p">(</span><span class="n">filter</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="k">if</span> <span class="k">let</span> <span class="nv">error</span> <span class="o">=</span> <span class="n">model</span><span class="o">.</span><span class="n">errorMessage</span> <span class="p">{</span>
<span class="kt">Text</span><span class="p">(</span><span class="n">error</span><span class="p">)</span><span class="o">.</span><span class="nf">foregroundStyle</span><span class="p">(</span><span class="o">.</span><span class="n">red</span><span class="p">)</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="o">.</span><span class="n">task</span> <span class="p">{</span> <span class="k">await</span> <span class="n">model</span><span class="o">.</span><span class="nf">loadCapability</span><span class="p">()</span> <span class="p">}</span>
<span class="o">.</span><span class="nf">task</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="n">isOpen</span> <span class="o">&&</span> <span class="n">model</span><span class="o">.</span><span class="n">capability</span><span class="p">?</span><span class="o">.</span><span class="n">savedFilters</span> <span class="o">==</span> <span class="kc">true</span><span class="p">)</span> <span class="p">{</span>
<span class="k">if</span> <span class="n">isOpen</span> <span class="p">{</span> <span class="k">await</span> <span class="n">model</span><span class="o">.</span><span class="nf">loadFilters</span><span class="p">()</span> <span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<p>The app’s authenticated <code class="language-plaintext highlighter-rouge">URLSession</code> would carry the user’s credentials; the sample leaves that wiring to the host app. When someone taps a filter, the host sends the ID to the order-list API rather than trusting the locally decoded query. I would test both clients with the same capability responses: on, off, failed request, and revocation after the screen has loaded. I would also test the API endpoint directly with the feature disabled. The server is the actual gate.</p>
<h2 id="the-switch-has-a-lifecycle">The switch has a lifecycle</h2>
<p>Across these examples, I would keep a little record for every flag: owner, default, scope, rollout plan, telemetry, rollback behavior, and removal date. The pipeline route is scoped to a tenant and batch. The shipping offer is scoped to a stable account cohort and then frozen into an order quote. The interface flag is scoped to an authenticated account, while the visible on/off control is a separate user preference.</p>
<p>That distinction is the useful mental model. A toggle switch is an operational decision point, and the rest of the system has to agree on what was decided. Give it one boundary, persist decisions when they affect money or durable data, measure the new path, and remove the temporary branch after the migration is over. Otherwise the switch becomes one more permanent mystery in the codebase, which is a lousy reward for trying to ship safely.</p>
<h3 id="references">References</h3>
<ul>
<li><a href="https://docs.databricks.com/aws/en/dev-tools/sql-execution-tutorial">Databricks Statement Execution API</a></li>
<li><a href="https://docs.databricks.com/aws/en/sql/language-manual/delta-copy-into">Databricks <code class="language-plaintext highlighter-rouge">COPY INTO</code></a></li>
<li><a href="https://docs.databricks.com/aws/en/sql/language-manual/delta-merge-into">Databricks <code class="language-plaintext highlighter-rouge">MERGE INTO</code></a></li>
<li><a href="https://node-postgres.com/features/queries">node-postgres parameterized queries</a></li>
</ul>
Agent-Ready Local Environments and MCP
https://compositecode.blog/2026/09/24/agent-ready-local-environments-and-mcp/
2026-09-24T12:37:01-07:00
2026-09-24T12:37:01-07:00
Getting a coding agent productive is less about the prompt and more about whether the machine in front of it can actually build, run, and reach the systems the work depends on. Cursor, Claude, Codex, and the rest are fast when the local world is already honest: dependencies install, the app boots, tests can run, …
<p>Getting a coding agent productive is less about the prompt and more about whether the machine in front of it can actually build, run, and reach the systems the work depends on.</p>
<p>Cursor, Claude, Codex, and the rest are fast when the local world is already honest: dependencies install, the app boots, tests can run, and the external surfaces (GitHub, GitLab, Slack, Outlook, Teams, whatever is in play) are reachable through clear, permissioned paths. When those pieces are missing, the agent spends its tokens rediscovering your laptop instead of doing the job.</p>
<p>This post is about making that setup cheap and repeatable.</p>
<h2 id="the-real-prerequisite-is-a-bootable-story">The real prerequisite is a bootable story</h2>
<p>Every repo should answer, in files the agent can read, a small set of questions:</p>
<ol>
<li>What do I need installed on this machine?</li>
<li>How do I get from clone to a running local system?</li>
<li>Which services does this project talk to, and which of those am I expected to use locally?</li>
<li>Where do secrets live, and what is <em>not</em> allowed?</li>
<li>How do I know the setup worked?</li>
</ol>
<p>If those answers only exist in your head, the agent will invent something almost right. Almost right is expensive.</p>
<p>I keep this in the product repo as first-class docs and scripts: <code class="language-plaintext highlighter-rouge">README</code>, <code class="language-plaintext highlighter-rouge">CONTRIBUTING</code>, <code class="language-plaintext highlighter-rouge">docs/setup</code>, <code class="language-plaintext highlighter-rouge">scripts/bootstrap</code>, <code class="language-plaintext highlighter-rouge">.env.example</code>. When the work spans more than one codebase, I also mirror the “where the rest of the world lives” map in the project notes repo.</p>
<h2 id="make-setup-mechanical">Make setup mechanical</h2>
<p>Agents are excellent at following a checklist. They are mediocre at inferring your undocumented brew taps and tribal knowledge.</p>
<p>What works well right now:</p>
<p><strong>A single bootstrap path.</strong> One script or documented sequence: install language runtimes, install dependencies, copy env templates, start local dependencies, run a smoke check. Prefer boring and explicit over clever.</p>
<p><strong>Pinned, discoverable tooling.</strong> Version files (<code class="language-plaintext highlighter-rouge">.nvmrc</code>, <code class="language-plaintext highlighter-rouge">.node-version</code>, <code class="language-plaintext highlighter-rouge">mise.toml</code>, <code class="language-plaintext highlighter-rouge">asdf</code> configs, <code class="language-plaintext highlighter-rouge">rust-toolchain</code>, etc.) beat README poetry. The agent should be able to detect the toolchain without asking you which Node major you meant last quarter.</p>
<p><strong><code class="language-plaintext highlighter-rouge">.env.example</code> that matches reality.</strong> Every required variable named. No secret values. Short comments for where to get each one. If a variable is only for production, say so.</p>
<p><strong>Smoke tests for the environment.</strong> A command that proves the local stack is alive: <code class="language-plaintext highlighter-rouge">npm run doctor</code>, <code class="language-plaintext highlighter-rouge">make verify</code>, a compose healthcheck, a migration status call. Green means “safe to start feature work.” Red means “fix the machine, not the feature.”</p>
<p><strong>Dev containers or explicit host setup. Pick one and document it.</strong> Either the agent works inside a known container, or it works on the host with a known bootstrap. Mixing both without saying which is canonical creates two slightly broken worlds.</p>
<p>This is the same instinct as my old <code class="language-plaintext highlighter-rouge">dev-setup-osx</code> habit, updated for a world where the “new developer” might be an agent that showed up thirty seconds ago.</p>
<h2 id="tell-the-agent-where-the-working-systems-are">Tell the agent where the working systems are</h2>
<p>Local build is only half the map. Most real work touches other systems: source hosts, chat, mail, issue trackers, cloud consoles, internal APIs.</p>
<p>Write down the topology.</p>
<ul>
<li>Product repos and their remotes (GitHub, GitLab, both, mirrors).</li>
<li>Which environments exist: local, staging, production, preview apps.</li>
<li>Which human collaboration surfaces matter: Slack channels, Teams teams, Outlook lists, Linear/Jira projects.</li>
<li>Which of those the agent is allowed to read or write.</li>
<li>How authentication is supposed to happen for each.</li>
</ul>
<p>Do not make the agent guess that “the deploy” means GitHub Actions in one repo and a GitLab pipeline in another. Put the pointers in markdown next to the code or in the notes repo. Ambiguity here turns into the wrong PR opened against the wrong remote, or a “fix” that never lands where humans look.</p>
<h2 id="mcp-is-how-agents-reach-the-rest-of-the-desk">MCP is how agents reach the rest of the desk</h2>
<p>Model Context Protocol servers are the practical bridge between the coding agent and the tools already on your desk. Used well, they turn “go check the thread / issue / inbox / pipeline” into a first-class action instead of a copy-paste scavenger hunt.</p>
<p>The useful pattern is not “connect everything.” It is “connect the systems this project actually depends on, with the least privilege that still helps.”</p>
<p>A sane MCP layout for project work often includes:</p>
<ul>
<li><strong>Source control:</strong> GitHub and/or GitLab for issues, PRs, checks, and releases.</li>
<li><strong>Chat:</strong> Slack or Teams for the channels where decisions and unblockers live.</li>
<li><strong>Mail/calendar:</strong> Outlook or similar when the work is genuinely gated on threads or meetings, not because it is fun to give an agent your inbox.</li>
<li><strong>Project tracking:</strong> whatever holds the tickets, if that is not already the git host.</li>
<li><strong>Docs and runbooks:</strong> if they live outside the repo and agents keep asking for them.</li>
</ul>
<p>Wire these at the user or project level in Cursor (and equivalents elsewhere), then document in the repo which MCP servers are expected for this project and what they are for. An agent that knows “Teams is available for the eng channel, GitHub is available for PRs, Outlook is not in scope for this repo” wastes less time and takes fewer weird actions.</p>
<p>Authentication matters. Prefer the product’s normal OAuth/device flows. Keep tokens out of the notes repo and out of prompts. If a server needs auth, say so in setup docs and stop there. Do not paste credentials into markdown “for convenience.”</p>
<h2 id="boundaries-beat-cleverness">Boundaries beat cleverness</h2>
<p>An agent with broad access and no rules will eventually do something technically impressive and socially awful: comment in the wrong channel, open a PR against the wrong fork, or dig through mail that was never part of the task.</p>
<p>Give it clear limits:</p>
<ul>
<li>Read-only by default where write access is not required.</li>
<li>Explicit allow-lists of repos, channels, and projects.</li>
<li>Repo instructions that say when to use MCP versus when to stay local.</li>
<li>The same ask-plan-confirm gate you use for code changes when the action leaves the laptop (posting, labeling, merging, emailing).</li>
</ul>
<p>Local environment setup gets the agent building. MCP gets the agent collaborating. Boundaries keep both from becoming a mess you have to unwind.</p>
<h2 id="a-minimal-checklist-i-actually-use">A minimal checklist I actually use</h2>
<p>When I stand up a project for agent-assisted work, I want at least this:</p>
<ol>
<li>Clone, bootstrap, and smoke test documented and scripted.</li>
<li>Toolchain versions pinned in-repo.</li>
<li><code class="language-plaintext highlighter-rouge">.env.example</code> complete; real secrets elsewhere.</li>
<li>Notes/brief that name the remotes, environments, and collaboration surfaces.</li>
<li>MCP servers configured for the systems in play, with purpose notes in the project docs.</li>
<li>Clear write/read expectations for each integration.</li>
<li>A first prompt that points the agent at setup docs before feature work.</li>
</ol>
<p>None of that is fancy. All of it is what makes Cursor, Claude, Codex, and friends look brilliant on day one instead of lost in your <code class="language-plaintext highlighter-rouge">PATH</code>.</p>
<p>The goal is simple: when an agent sits down at the project, the local world boots, the surrounding systems are findable, and the rules of engagement are already written down.</p>
Twenty-ish Terminal Commands for Getting Around the File System
https://compositecode.blog/2026/09/11/20-terminal-commands/
2026-09-11T09:49:05-07:00
2026-09-23T14:09:34-07:00
This content provides a comprehensive guide to terminal commands, focusing on file navigation and management in macOS and Linux. It highlights essential commands like ls, pwd, cd, tree, find, and others, detailing their functionalities and useful options. The guide aims to enhance user efficiency in file-system navigation through command-line tools.
<p>Recently I was digging around in the terminal and wanted an <code class="language-plaintext highlighter-rouge">ls</code> command that behaved like a fully opened tree view. That led me back through a bunch of the file-system commands I use all the time, along with a few switches that make them far more useful.</p>
<p>This is the resulting quick reference. I’m writing this on macOS with <code class="language-plaintext highlighter-rouge">zsh</code>, but most of it applies directly to Linux too. A few tools—particularly <code class="language-plaintext highlighter-rouge">tree</code> and <code class="language-plaintext highlighter-rouge">fd</code>—may need to be installed first.</p>
<h2 id="first-the-many-faces-of-ls">First, the Many Faces of <code class="language-plaintext highlighter-rouge">ls</code></h2>
<p>The plain command lists the current directory.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span>
</code></pre></div></div>
<p>Add <code class="language-plaintext highlighter-rouge">-l</code> for the vertical, long-listing format. This shows permissions, link count, owner, group, size, modified date, and name.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> <span class="nt">-l</span>
</code></pre></div></div>
<p>Add <code class="language-plaintext highlighter-rouge">-a</code> to include hidden entries—names beginning with a dot—and <code class="language-plaintext highlighter-rouge">-h</code> to make sizes human-readable. The switches can be combined.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> <span class="nt">-lah</span>
</code></pre></div></div>
<p>To descend through every directory and list its contents, add <code class="language-plaintext highlighter-rouge">-R</code> for recursive.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls</span> <span class="nt">-laRh</span>
</code></pre></div></div>
<p>That gets us the fully opened contents, although the output is grouped by directory rather than drawn as a visual tree.</p>
<h2 id="navigation">Navigation</h2>
<h3 id="1-pwd--where-am-i">1. <code class="language-plaintext highlighter-rouge">pwd</code> — Where Am I?</h3>
<p>When I’ve wandered six directories deep and forgotten where I am, <code class="language-plaintext highlighter-rouge">pwd</code> prints the full path to the working directory.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">pwd</span>
</code></pre></div></div>
<h3 id="2-cd--change-directory">2. <code class="language-plaintext highlighter-rouge">cd</code> — Change Directory</h3>
<p>Move into a directory, up one level, back to the previous directory, or straight home.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>projects
<span class="nb">cd</span> ..
<span class="nb">cd</span> -
<span class="nb">cd</span> ~
</code></pre></div></div>
<p>Paths containing spaces need quotes, as in <code class="language-plaintext highlighter-rouge">cd "My Projects"</code>.</p>
<h3 id="3-pushd-and-popd--directory-bookmarks-sort-of">3. <code class="language-plaintext highlighter-rouge">pushd</code> and <code class="language-plaintext highlighter-rouge">popd</code> — Directory Bookmarks, Sort Of</h3>
<p><code class="language-plaintext highlighter-rouge">pushd</code> changes directories while saving the current location on a stack. <code class="language-plaintext highlighter-rouge">popd</code> takes me back. This is excellent when bouncing between two distant parts of a repository.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">pushd</span> ~/Code/my-project/docs
<span class="nb">popd</span>
</code></pre></div></div>
<h3 id="4-tree--the-actual-tree-view">4. <code class="language-plaintext highlighter-rouge">tree</code> — The Actual Tree View</h3>
<p>Unlike recursive <code class="language-plaintext highlighter-rouge">ls</code>, <code class="language-plaintext highlighter-rouge">tree</code> draws the hierarchy. <code class="language-plaintext highlighter-rouge">-a</code> includes hidden entries, <code class="language-plaintext highlighter-rouge">-L 2</code> limits output to two levels, and <code class="language-plaintext highlighter-rouge">-h</code> prints readable sizes.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tree <span class="nt">-a</span> <span class="nt">-h</span> <span class="nt">-L</span> 2
</code></pre></div></div>
<p>On macOS, install it with <code class="language-plaintext highlighter-rouge">brew install tree</code> if it is not already available.</p>
<h2 id="finding-and-inspecting-things">Finding and Inspecting Things</h2>
<h3 id="5-find--search-the-directory-tree">5. <code class="language-plaintext highlighter-rouge">find</code> — Search the Directory Tree</h3>
<p>Find every Markdown file below the current directory. Here <code class="language-plaintext highlighter-rouge">.</code> means “start here,” <code class="language-plaintext highlighter-rouge">-type f</code> limits results to files, and <code class="language-plaintext highlighter-rouge">-name</code> supplies the filename pattern.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find <span class="nb">.</span> <span class="nt">-type</span> f <span class="nt">-name</span> <span class="s2">"*.md"</span>
</code></pre></div></div>
<p>Use <code class="language-plaintext highlighter-rouge">-type d</code> to find directories instead.</p>
<h3 id="6-fd--a-friendlier-find">6. <code class="language-plaintext highlighter-rouge">fd</code> — A Friendlier Find</h3>
<p><code class="language-plaintext highlighter-rouge">fd</code> provides a concise, fast alternative when installed. This finds Markdown files while including hidden paths but excluding <code class="language-plaintext highlighter-rouge">.git</code>.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fd <span class="nt">--hidden</span> <span class="nt">--exclude</span> .git <span class="s1">'\.md$'</span>
</code></pre></div></div>
<p>Install it on macOS with <code class="language-plaintext highlighter-rouge">brew install fd</code>.</p>
<h3 id="7-stat--all-the-file-details">7. <code class="language-plaintext highlighter-rouge">stat</code> — All the File Details</h3>
<p><code class="language-plaintext highlighter-rouge">stat</code> reports metadata such as size, permissions, timestamps, and inode information.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">stat </span>quick-article.md
</code></pre></div></div>
<p>The exact output differs between macOS and Linux, but the intent is the same.</p>
<h3 id="8-file--what-is-this-thing">8. <code class="language-plaintext highlighter-rouge">file</code> — What Is This Thing?</h3>
<p>Extensions can lie. <code class="language-plaintext highlighter-rouge">file</code> inspects the contents and reports what it believes the file actually is.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>file mysterious-download
</code></pre></div></div>
<h3 id="9-du--what-is-using-the-space">9. <code class="language-plaintext highlighter-rouge">du</code> — What Is Using the Space?</h3>
<p><code class="language-plaintext highlighter-rouge">du</code> measures disk usage. <code class="language-plaintext highlighter-rouge">-s</code> summarizes instead of listing everything below the target, and <code class="language-plaintext highlighter-rouge">-h</code> makes the result readable.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">du</span> <span class="nt">-sh</span> <span class="nb">.</span>
<span class="nb">du</span> <span class="nt">-sh</span> ./<span class="k">*</span>
</code></pre></div></div>
<h3 id="10-df--how-much-disk-is-left">10. <code class="language-plaintext highlighter-rouge">df</code> — How Much Disk Is Left?</h3>
<p>Where <code class="language-plaintext highlighter-rouge">du</code> examines files and directories, <code class="language-plaintext highlighter-rouge">df</code> reports free and used space on mounted file systems. Again, <code class="language-plaintext highlighter-rouge">-h</code> keeps the numbers readable.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">df</span> <span class="nt">-h</span>
</code></pre></div></div>
<h3 id="11-less--read-without-flooding-the-terminal">11. <code class="language-plaintext highlighter-rouge">less</code> — Read Without Flooding the Terminal</h3>
<p>For a file longer than one screen, use <code class="language-plaintext highlighter-rouge">less</code>. Search with <code class="language-plaintext highlighter-rouge">/text</code>, advance with the space bar, move backward with <code class="language-plaintext highlighter-rouge">b</code>, and quit with <code class="language-plaintext highlighter-rouge">q</code>.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>less application.log
</code></pre></div></div>
<h3 id="12-head-and-tail--inspect-the-edges">12. <code class="language-plaintext highlighter-rouge">head</code> and <code class="language-plaintext highlighter-rouge">tail</code> — Inspect the Edges</h3>
<p>These show the beginning or end of a file. <code class="language-plaintext highlighter-rouge">-n 20</code> asks for twenty lines. <code class="language-plaintext highlighter-rouge">tail -f</code> keeps watching as new lines arrive, which is particularly handy for logs.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">head</span> <span class="nt">-n</span> 20 application.log
<span class="nb">tail</span> <span class="nt">-n</span> 20 application.log
<span class="nb">tail</span> <span class="nt">-f</span> application.log
</code></pre></div></div>
<h2 id="creating-and-managing-files">Creating and Managing Files</h2>
<h3 id="13-mkdir--create-directories">13. <code class="language-plaintext highlighter-rouge">mkdir</code> — Create Directories</h3>
<p>The useful switch here is <code class="language-plaintext highlighter-rouge">-p</code>: it creates missing parent directories and does not complain if the path already exists.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> notes/terminal/examples
</code></pre></div></div>
<h3 id="14-touch--create-an-empty-file">14. <code class="language-plaintext highlighter-rouge">touch</code> — Create an Empty File</h3>
<p>If the file does not exist, <code class="language-plaintext highlighter-rouge">touch</code> creates it. If it does exist, <code class="language-plaintext highlighter-rouge">touch</code> updates its timestamps without changing the contents.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">touch </span>notes.md
</code></pre></div></div>
<h3 id="15-cp--copy">15. <code class="language-plaintext highlighter-rouge">cp</code> — Copy</h3>
<p>Copy a file with plain <code class="language-plaintext highlighter-rouge">cp</code>. Use <code class="language-plaintext highlighter-rouge">-R</code> to copy a directory recursively and <code class="language-plaintext highlighter-rouge">-i</code> to ask before overwriting something.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> <span class="nt">-i</span> notes.md notes-backup.md
<span class="nb">cp</span> <span class="nt">-Ri</span> source-folder destination-folder
</code></pre></div></div>
<h3 id="16-mv--move-or-rename">16. <code class="language-plaintext highlighter-rouge">mv</code> — Move or Rename</h3>
<p><code class="language-plaintext highlighter-rouge">mv</code> handles both jobs. I often include <code class="language-plaintext highlighter-rouge">-i</code> for an overwrite prompt.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mv</span> <span class="nt">-i</span> draft.md published.md
<span class="nb">mv</span> <span class="nt">-i</span> published.md archive/
</code></pre></div></div>
<h3 id="17-ln--create-a-link">17. <code class="language-plaintext highlighter-rouge">ln</code> — Create a Link</h3>
<p><code class="language-plaintext highlighter-rouge">ln -s</code> creates a symbolic link. The first path is the existing target; the second is the new link.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ln</span> <span class="nt">-s</span> ~/Code/my-project/current-config.json config.json
</code></pre></div></div>
<h3 id="18-rmdir--remove-an-empty-directory">18. <code class="language-plaintext highlighter-rouge">rmdir</code> — Remove an Empty Directory</h3>
<p><code class="language-plaintext highlighter-rouge">rmdir</code> only removes empty directories. That limitation makes it useful when I want the command to refuse anything containing files.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rmdir </span>old-empty-folder
</code></pre></div></div>
<h3 id="19-rm--remove-files-carefully">19. <code class="language-plaintext highlighter-rouge">rm</code> — Remove Files Carefully</h3>
<p>Plain <code class="language-plaintext highlighter-rouge">rm</code> removes files. <code class="language-plaintext highlighter-rouge">-i</code> asks before each removal. Recursive <code class="language-plaintext highlighter-rouge">-R</code> removes directories and their contents, so double-check the path before pressing Return.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rm</span> <span class="nt">-i</span> unwanted.txt
<span class="nb">rm</span> <span class="nt">-Ri</span> unwanted-folder
</code></pre></div></div>
<p>There generally is no built-in undo. On macOS, moving something to the Trash in Finder is often a better choice when I’m not absolutely sure.</p>
<h3 id="20-open--hand-it-to-macos">20. <code class="language-plaintext highlighter-rouge">open</code> — Hand It to macOS</h3>
<p><code class="language-plaintext highlighter-rouge">open</code> opens a file in its default application. Give it a directory and Finder opens there; use <code class="language-plaintext highlighter-rouge">-a</code> to choose an application.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>open README.md
open <span class="nb">.</span>
open <span class="nt">-a</span> <span class="s2">"Visual Studio Code"</span> <span class="nb">.</span>
</code></pre></div></div>
<p>On Linux, the closest general equivalent is usually <code class="language-plaintext highlighter-rouge">xdg-open</code>.</p>
<h2 id="a-few-combinations-i-actually-use">A Few Combinations I Actually Use</h2>
<p>Here are the quick combinations I tend to reach for most often.</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Everything here, including hidden entries, with readable details.</span>
<span class="nb">ls</span> <span class="nt">-lah</span>
<span class="c"># Everything below here, recursively, in long format.</span>
<span class="nb">ls</span> <span class="nt">-laRh</span>
<span class="c"># A manageable visual overview, two levels deep.</span>
tree <span class="nt">-a</span> <span class="nt">-L</span> 2
<span class="c"># Find the biggest immediate entries in the current directory.</span>
<span class="nb">du</span> <span class="nt">-sh</span> ./<span class="k">*</span> | <span class="nb">sort</span> <span class="nt">-h</span>
<span class="c"># Jump somewhere temporarily, inspect it, and jump back.</span>
<span class="nb">pushd</span> ~/Code/some-project
<span class="nb">ls</span> <span class="nt">-lah</span>
<span class="nb">popd</span>
</code></pre></div></div>
<p>That’s the lot: twenty-ish commands and a pile of switches that turn the terminal into a quick file-system navigator. The commands themselves are only half of the story. Run <code class="language-plaintext highlighter-rouge">man ls</code>, <code class="language-plaintext highlighter-rouge">man find</code>, or <code class="language-plaintext highlighter-rouge">man</code> followed by any of the built-in commands above to spelunk through everything else they can do.</p>
Dashing Arrivals: Watch Every Bus, Train, and Ferry in Puget Sound Move in Real Time
https://compositecode.blog/2026/08/31/dashingarrivals/
2026-08-31T18:29:09-07:00
2026-08-31T18:29:09-07:00
Dashing Arrivals is a live transit map for the Puget Sound region, displaying real-time locations of over 1,100 buses, trains, and ferries. Users can click on vehicles for details, view routes, and receive live arrival predictions. The app is an effective tool for commuting and monitoring transit activity.
<p>There’s a particular kind of magic in watching a whole city move at once. Not one bus, not one train — <em>all</em> of them, gliding across the map in real time, each one a little rectangle of somebody’s commute home.</p>
<p>That’s <a href="https://dashingarrivals.com"><strong>Dashing Arrivals</strong></a> — a live transit map for the Puget Sound region. Open it up and you’re looking at every active bus, train, and ferry in service right now, from a King County Metro coach on Rainier Avenue to a Sound Transit Link train sliding through the Rainier Valley to a Washington State Ferry crossing to Bainbridge. On a typical weekday afternoon that’s <strong>over 1,100 vehicles</strong> on screen, all updating live.</p>
<p>This post is a tour of what it is and what you can do with it.</p>
<h2 id="the-whole-region-at-a-glance">The whole region at a glance</h2>
<p><a href="/wp-content/uploads/2026/08/overview.png"><img src="/wp-content/uploads/2026/08/overview.png" alt="An overview of the city in transit." /></a></p>
<p>The default view drops you over Puget Sound with everything turned on. A quick legend does a lot of work here: <strong>color tells you the agency</strong> and <strong>shape tells you the type</strong> (bus, train, or ferry), and every icon is rotated to face the direction it’s actually traveling. Green is King County Metro, blue/indigo is Sound Transit, red is Pierce Transit, purple is Kitsap, and so on — seven agencies in all, plus the ferries.</p>
<p>Pan, zoom, and the icons resize smoothly so trains read a little larger than buses, which read a little larger than ferries. Between updates, vehicles don’t teleport — they <strong>animate from their last position to the next one</strong>, so the whole map has a calm, continuous, living quality instead of a jumpy refresh.</p>
<h2 id="click-any-vehicle-for-the-details">Click any vehicle for the details</h2>
<p><a href="/wp-content/uploads/2026/08/popup-link.png"><img src="/wp-content/uploads/2026/08/popup-link.png" alt="Showing Route 1 Line" /></a></p>
<p>Tap a vehicle and it tells you who it is: the route, the agency, the fleet number, its heading (as a compass direction <em>and</em> degrees), its speed, and when it last reported in. Here’s a <strong>1 Line</strong> Link train in the Rainier Valley, heading south at 159°.</p>
<p>See that <strong>Show route ▸</strong> link at the top of the popup? That’s where it gets fun.</p>
<h2 id="see-the-entire-route">See the entire route</h2>
<p>Click <strong>Show route</strong> and the map draws the vehicle’s full line — the shape it follows and every stop along the way — right under the live vehicles still moving on it.</p>
<p><img src="/wp-content/uploads/2026/08/route-link-1line.png" alt="Link light rail line 1 in the south city." /></p>
<p>Here’s the <strong>1 Line</strong>, Link light rail’s spine, threading from the north down through downtown, Beacon Hill, and the Rainier Valley. The blue train icons strung along the highlighted line are the actual trains in service on it right now.</p>
<p><a href="/wp-content/uploads/2026/08/route-link-2line.png"><img src="/wp-content/uploads/2026/08/route-link-2line.png" alt="Link Light Rail 2 Line" /></a></p>
<p>And the newer <strong>2 Line</strong>, running across Lake Washington on I‑90 to Bellevue and up to Downtown Redmond. (Fun bit of local history: the 2 Line effectively replaced the old ST Express 550 bus between Seattle and Bellevue — which is why you won’t find a 550 on the map anymore.)</p>
<h2 id="rail-beyond-link-the-sounder">Rail beyond Link: the Sounder</h2>
<p>Light rail isn’t the only train out there. Pick a <strong>Sounder</strong> commuter-rail train and you get the full BNSF corridor.</p>
<p><a href="/wp-content/uploads/2026/08/route-sounder-sline.png"><img src="/wp-content/uploads/2026/08/route-sounder-sline.png" alt="South Sounder Commuter Rail Line." /></a></p>
<p>This is the <strong>S Line</strong>, running from King Street Station in Seattle south through Tukwila, Kent, Auburn, and on toward Tacoma and Lakewood. Sounder only runs during peak commute windows, so catching one on the map is a small, satisfying reward for looking at the right time — and down in Tacoma you can see Pierce Transit’s red buses fill in the local network.</p>
<h2 id="buses-of-course--every-route">Buses, of course — every route</h2>
<p>The same trick works for any bus. Click one, hit <strong>Show route</strong>, and the corridor lights up in that agency’s color.</p>
<p><a href="/wp-content/uploads/2026/08/route-bus-cline.png"><img src="/wp-content/uploads/2026/08/route-bus-cline.png" alt="C Line Rapid Ride." /></a></p>
<p>The <strong>RapidRide C Line</strong> from West Seattle into downtown, in Metro green.</p>
<p><a href="/wp-content/uploads/2026/08/route-bus-eline.png"><img src="/wp-content/uploads/2026/08/route-bus-eline.png" alt="E Line Rapid Ride." /></a></p>
<p>And the <strong>RapidRide E Line</strong>, a ruler-straight shot down Aurora Avenue from Shoreline to downtown — one of the busiest bus corridors in the state. Whether it’s a lettered RapidRide line, a numbered local route, or a Community Transit Swift line, the route overlay works the same way.</p>
<h2 id="filter-the-firehose">Filter the firehose</h2>
<p>Eleven-hundred vehicles is a lot. The <strong>Show</strong> and <strong>Agencies</strong> panel lets you dial it in — toggle whole modes or individual agencies on and off.</p>
<p><a href="/wp-content/uploads/2026/08/filter-rail-ferry.png"><img src="/wp-content/uploads/2026/08/filter-rail-ferry.png" alt="Filtering with busses off and only showing the trains and ferries." /></a></p>
<p>Here I’ve switched <strong>off buses</strong> to leave just trains and ferries. Suddenly the shape of the rail network jumps out — the Link lines tracing north–south and across the lake — alongside the ferries stitching the Sound together to Bainbridge, Bremerton, Kingston, and Vashon. It’s the whole regional rail-and-water map, drawn entirely by the vehicles themselves.</p>
<h2 id="whens-my-ride-live-arrivals-at-any-stop">When’s my ride? Live arrivals at any stop</h2>
<p>Zoom in and the stops appear. Click one to get a live arrivals board.</p>
<p><a href="/wp-content/uploads/2026/08/stop-arrivals.png"><img src="/wp-content/uploads/2026/08/stop-arrivals.png" alt="Stop arrivals." /></a></p>
<p>This is the <strong>Symphony</strong> stop downtown, with real-time predictions counting down — <em>Due, Due, 1 min, 4 min, 6 min…</em> — for 1 Line and 2 Line trains toward Lynnwood City Center. Each prediction carries a live indicator, and any alerts that affect this specific stop are pinned right at the top.</p>
<h2 id="know-before-you-go-service-alerts">Know before you go: service alerts</h2>
<p><a href="/wp-content/uploads/2026/08/service-alerts.png"><img src="/wp-content/uploads/2026/08/service-alerts.png" alt="Service alerts!" /></a></p>
<p>The pill at the top of the screen keeps a running count of active <strong>service alerts</strong> across the region — detours, stop relocations, reduced service, construction. Open it and you can read them all: each card shows the agency, the affected routes, the reroute instructions, and a link out to the agency’s own detail page. It’s the regional “what’s disrupted right now” board in one place.</p>
<h2 id="light-or-dark-your-call">Light or dark, your call</h2>
<p><a href="/wp-content/uploads/2026/08/theme-light.png"><img src="/wp-content/uploads/2026/08/theme-light.png" alt="" /></a></p>
<p>Prefer a bright map? A theme toggle switches between a sleek dark basemap and a clean light one (with a <strong>System</strong> option that follows your OS). Same live data, different mood.</p>
<h2 id="under-the-hood">Under the hood</h2>
<p>For the curious, Dashing Arrivals is a modern, open-source web app:</p>
<ul>
<li><strong>Live data</strong> comes from the <strong>OneBusAway</strong> Puget Sound regional API, decoded from <strong>GTFS‑realtime</strong> vehicle-position feeds. A single regional feed covers King County Metro, Sound Transit, Community Transit, Pierce Transit, Kitsap Transit, and Everett Transit.</li>
<li><strong>Ferries</strong> come from a separate source — the <strong>WSDOT</strong> Washington State Ferries vessel-location API — and are normalized in alongside everything else.</li>
<li>The map is <strong>MapLibre GL</strong> on an open <strong>OpenFreeMap</strong> basemap (no proprietary map key required), with the app built on <strong>Next.js</strong> and <strong>React</strong> and deployed on <strong>Vercel</strong>.</li>
<li>Vehicle positions are fetched on a short polling loop and cached server-side, then <strong>eased between updates</strong> on the client so motion looks smooth. Icons are tinted per agency, sized by zoom, and rotated to the reported heading.</li>
</ul>
<p>Eventually the GTFS feed is going to have some changes, and I plan to put those fixes in then, but in the meantime this is a solid way to explore the Seattle and Puget Sound area’s transit options!</p>
<h2 id="go-watch-the-city-move">Go watch the city move</h2>
<p>That’s the whole pitch: one map, every vehicle, in real time, with routes, stops, arrivals, and alerts a click away. It’s genuinely useful for catching a bus — and, if you’re the type who likes watching systems work, it’s a little bit hypnotic.</p>
<p><strong>Try it for yourself at <a href="https://dashingarrivals.com">dashingarrivals.com</a>.</strong></p>
<hr />
<p><em>Screenshots captured live from dashingarrivals.com. Basemap © OpenFreeMap / OpenMapTiles, data © OpenStreetMap contributors. Real-time transit data via OneBusAway and WSDOT.</em></p>
Ask, Plan, Confirm: Making Agents Stop Before They Start
https://compositecode.blog/2026/08/20/plan-then-build/
2026-08-20T08:00:00-07:00
2026-08-20T11:58:12-07:00
The text emphasizes the importance of a structured workflow for coding agents to prevent unintended changes. By introducing a three-step gating process—Ask, Plan, and Confirm—developers can ensure that agents check for ambiguities, outline necessary changes, and obtain explicit approval before implementation. This approach mitigates risks and enhances code quality.
<p>The scariest thing about a genuinely capable coding agent is how quickly it commits. You type two sentences, and forty seconds later there are changes across nine files, half of which you did not want and one of which quietly changed a query you spent a week hardening. The agent was not wrong about how to implement the thing. It was wrong about what the thing was, and it never stopped to check.</p>
<p>I have watched this happen enough times that I stopped treating it as a prompting problem and started treating it as a workflow problem. A better one-shot prompt is not the fix. A gate is: the agent is not allowed to edit files until it has asked what it needs to ask, shown me a plan, and gotten a yes.</p>
<h2 id="why-speed-is-the-problem">Why speed is the problem</h2>
<p>Human engineers have a built-in pause. Before a senior developer touches your codebase they ask a couple of questions, sketch the approach, maybe drop a comment on the ticket. That pause is where the wrong-work gets caught, cheaply, in a sentence, instead of expensively, in a diff you have to read and reject.</p>
<p>Agents removed the pause. That is most of their value and most of their danger in the same motion. An agent that implements immediately optimizes for the wrong thing: it treats “produce a diff” as the goal, when the goal was “produce the diff we agreed on.” The gap between those two is where the deleted work and the silent scope creep live.</p>
<p>That gap is not a knowledge problem. The agent knows how to write the code. It just never checked that it was writing the right code.</p>
<p><a href="/wp-content/uploads/2026/08/plan-first-fig2-why-speed.png"><img src="/wp-content/uploads/2026/08/plan-first-fig2-why-speed.png" alt="Diagram explaining the importance of speed in work processes, contrasting 'no gate' and 'the gate' approaches. It highlights potential problems and costs associated with each method." /></a></p>
<p><em>The same task, gated and ungated. Skipping the pause trades a small, predictable cost for a large, unpredictable one.</em></p>
<p>So I gave the pause back, deliberately, as a standing instruction on every agent in the InterlinedList repo.</p>
<h2 id="the-three-beats">The three beats</h2>
<p>The workflow is written down in <code class="language-plaintext highlighter-rouge">.claude/workflows/plan-first.md</code> and every agent links to it. It is three beats, in order, and implementation is gated behind all three.</p>
<p><a href="/wp-content/uploads/2026/08/plan-first-fig1-the-gate.png"><img src="/wp-content/uploads/2026/08/plan-first-fig1-the-gate.png" alt="A flowchart titled 'The Gate' illustrating a process for implementation that requires three steps: Prompt, Ask, Plan, Confirm, and Implement. It emphasizes the sequence and conditions under which implementation occurs, highlighting the need for clarification and approval before proceeding." /></a></p>
<p><em>The three beats, in order. Nothing is edited until all three pass, and work that grows past the approved plan loops back to re-plan rather than quietly expanding.</em></p>
<p><strong>Ask.</strong> Surface what the prompt left open before committing to an approach. Ambiguous scope, unstated edge cases, a product decision hiding inside a technical request, whether a feature should be tier-gated. The migrations agent asks about column types and nullability and whether anything destructive is implied. The Next.js agent asks which surfaces are in and out. The rule has an escape hatch, because asking three questions about a one-line copy fix is its own kind of annoying: skip the questions only when the request is genuinely unambiguous and low-risk. When in doubt, ask. A pointed question is cheaper than a wrong build every single time.</p>
<p><strong>Plan.</strong> Before touching files, lay out the shape of the change: the files and routes and components you will touch, the ones you will deliberately leave alone, the approach, any migration (additive, always), the tests the change needs, and anything risky. In this repo “risky” has a specific meaning: auth, IDOR, subscription gating, SSRF, secret handling, anything destructive or hard to reverse. The plan is a decision aid, not a document. It should be short enough to read in one breath and specific enough that approving it means something.</p>
<p><strong>Confirm.</strong> Implement only after an explicit yes. If the plan changes in the back-and-forth, restate the revised version and get the yes again. And the part that actually matters over a long session: approval is scoped to the plan that was approved. If the work grows past it, the agent stops and re-plans instead of quietly expanding. That last clause is what keeps a “small fix” from turning into an afternoon of changes I never signed off on.</p>
<h2 id="what-it-looks-like-per-agent">What it looks like per agent</h2>
<p>I did not want one generic paragraph pasted eight times. The gate is the same, but what you ask about depends on the job, so each agent got the beats written for its lane.</p>
<p><a href="/wp-content/uploads/2026/08/plan-first-fig3-per-agent.png"><img src="/wp-content/uploads/2026/08/plan-first-fig3-per-agent.png" alt="A diagram featuring multiple lanes labeled with different topics: Migrations, Next.js, End-to-end, Docs, Unit testing, and Blog. Each lane has brief descriptions of its scope, accompanied by specific tags." /></a></p>
<p><em>One gate, written for each lane. The two read-only reviewers pick the full gate back up the moment they move from finding to fixing.</em></p>
<p>The <strong>migrations</strong> agent plans the exact idempotent <code class="language-plaintext highlighter-rouge">migration.sql</code> and confirms it is purely additive before it applies anything. The <strong>unit-testing</strong> agent asks which behaviors to lock in and which boundaries to mock, then lists the cases each test file will assert. The <strong>e2e</strong> agent names the flows, the auth and seed prerequisites, and the breakpoints that matter. The <strong>docs</strong> agent confirms which of the three docs is in scope and whether a new page is needed. The <strong>blog</strong> agent (yes, this one) settles the angle and the section arc and which real code it will verify claims against before drafting a word.</p>
<p>The two read-only reviewers are the interesting edge. Security and UX do not implement, so there is no edit to gate. For them the gate degrades to its first beat: confirm the review scope if it is ambiguous (which routes, how deep, which breakpoints), then produce findings. But the moment the user says “now fix what you found,” they are implementers, and the full ask-plan-confirm gate snaps back on before they touch code. The reviewer does not get to slide from “here is a finding” into “and I fixed it” without crossing the same line everyone else crosses.</p>
<h2 id="the-obvious-objection">The obvious objection</h2>
<p>This is slower. That is the point, and it is also not as true as it sounds. The plan step costs you a few seconds and one read. Rejecting a forty-second nine-file diff that went the wrong direction costs you the read plus the reject plus the re-prompt plus the nagging worry about what it touched that you did not catch. The gate front-loads a small, predictable cost to avoid a larger, unpredictable one. Over a day of handoffs it is not close.</p>
<p>It also composes with the other habit I built into these agents: every one of them does its work in an isolated <a href="/2026/08/19/worktrees/">git worktree</a>, on its own branch, torn down when the task lands. Plan first, then do the approved work in a sandbox that cannot collide with anyone else. The worktree contains the blast radius. The plan makes sure there is not supposed to be a blast in the first place.</p>
<p>The pause was always the expensive part of good engineering. Worth teaching the machines to keep it.</p>
<p><em>I’m <a href="https://interlinedlist.com/user/adron">Adron</a>, brainstorming and building <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
Giving Every Agent Its Own Branch: Git Worktrees for Parallel AI Work
https://compositecode.blog/2026/08/19/worktrees/
2026-08-19T08:00:00-07:00
2026-08-17T14:42:09-07:00
The text discusses challenges faced when using multiple coding agents on a single Git working tree, leading to merge conflicts and lost edits. It emphasizes the need for independent work environments through Git worktrees, which allow each agent to operate in its own directory while sharing an object store. The author details a structured solution to managing these worktrees, outlining scripts for creation, management, and cleanup while acknowledging remaining pitfalls such as shared databases and overlapping edits that could still lead to conflicts.
<p>Run two coding agents against a single working tree and they will fight. One is halfway through editing <code class="language-plaintext highlighter-rouge">app/api/messages/route.ts</code> while the other checks out a different branch underneath it. The index lock flickers. A <code class="language-plaintext highlighter-rouge">git stash</code> from one session swallows the other’s uncommitted work. I have lost real edits this way, and every time the root cause was the same: one working tree, one <code class="language-plaintext highlighter-rouge">HEAD</code>, two writers.</p>
<p>The InterlinedList repo already had matching agents for the jobs I hand off most: a Next.js implementer, a migrations specialist, unit and e2e testers, a docs writer, security and UX reviewers. They are good at their lanes. What they were missing was a lane in the literal sense. They all drove on one road.</p>
<p>I found the pattern I wanted written up in <a href="https://www.augmentcode.com/guides/git-worktrees-parallel-ai-agent-execution">Augment’s guide to git worktrees for parallel AI execution</a>, and it maps almost one to one onto how I already think about agents. This is the writeup of what I built on top of it for this repo: the directory convention, the seven scripts that manage the lifecycle, and the wiring that makes every agent use them without being reminded.</p>
<h2 id="the-one-tree-problem">The one-tree problem</h2>
<p>A normal clone gives you a single working directory backed by one <code class="language-plaintext highlighter-rouge">.git</code>. That is fine for one person doing one thing. The moment you parallelize, the shared mutable state (the working files, the index, the current branch) becomes the bottleneck. You cannot have the migrations agent on <code class="language-plaintext highlighter-rouge">agent/add-webhooks</code> and the docs agent on <code class="language-plaintext highlighter-rouge">agent/help-refresh</code> at the same instant, because “the branch” is a property of the whole checkout.</p>
<p><a href="/wp-content/uploads/2026/08/worktrees-fig1-one-tree-problem.png"><img src="/wp-content/uploads/2026/08/worktrees-fig1-one-tree-problem.png" alt="Diagram illustrating 'The one-tree problem' in software development with two agents, A and B, interacting with a mutable checkout and related issues like index.lock flicker and checkout thrash." /></a></p>
<p><em>Two writers, one mutable checkout. The collisions have nothing to do with the work: an index lock flickers, a <code class="language-plaintext highlighter-rouge">git stash</code> swallows the other session’s edits, <code class="language-plaintext highlighter-rouge">HEAD</code> thrashes between branches.</em></p>
<p>Git solved this in 2015 with <code class="language-plaintext highlighter-rouge">git worktree</code>. A worktree is a second (third, fourth) working directory attached to the same repository. Each one has its own files, its own index, and its own checked-out branch, while sharing one object store on disk. The object store is the expensive part, so you share it. A directory of files is cheap, so you duplicate it per task. That is exactly the tradeoff you want for parallel agents.</p>
<p><a href="/wp-content/uploads/2026/08/worktrees-fig2-architecture.png"><img src="/wp-content/uploads/2026/08/worktrees-fig2-architecture.png" alt="Diagram illustrating a Git object store with multiple worktrees, highlighting shared storage and individual configurations for each task." /></a></p>
<p><em>Share the expensive part, duplicate the cheap part. The object store lives on disk once; the working files, the index, the checked-out branch, and even the dev-server port are private to each worktree.</em></p>
<h2 id="the-convention">The convention</h2>
<p>Every agent works in <code class="language-plaintext highlighter-rouge">.trees/<task-id></code> on a branch named <code class="language-plaintext highlighter-rouge">agent/<task-id></code>, cut from <code class="language-plaintext highlighter-rouge">origin/develop</code> (this repo integrates on <code class="language-plaintext highlighter-rouge">develop</code>, not <code class="language-plaintext highlighter-rouge">main</code>). So a task called <code class="language-plaintext highlighter-rouge">add-list-webhooks</code> lives at <code class="language-plaintext highlighter-rouge">.trees/add-list-webhooks</code> on branch <code class="language-plaintext highlighter-rouge">agent/add-list-webhooks</code>. The directory name and the branch name always agree because both are derived from the same sanitized slug.</p>
<p>The <code class="language-plaintext highlighter-rouge">.trees/</code> container is gitignored. Worktrees are workspace, not history:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># .gitignore
# Agent worktrees (see scripts/worktrees/)
.trees/
</code></pre></div></div>
<p>That single ignore line is the whole footprint the pattern leaves in the tracked tree. Everything else is scripts and instructions.</p>
<h2 id="the-scripts">The scripts</h2>
<p>I did not want agents (or me) typing raw <code class="language-plaintext highlighter-rouge">git worktree</code> incantations and getting the branch name wrong, or forgetting to copy <code class="language-plaintext highlighter-rouge">.env.local</code>, or leaving stale metadata behind. So the lifecycle lives in <code class="language-plaintext highlighter-rouge">scripts/worktrees/</code> as a small set of focused shell scripts. Here is every one of them.</p>
<p><a href="/wp-content/uploads/2026/08/worktrees-fig3-lifecycle.png"><img src="/wp-content/uploads/2026/08/worktrees-fig3-lifecycle.png" alt="Diagram illustrating the worktree lifecycle in Git, detailing steps for creating, inspecting, removing, and cleaning worktrees. Sections include 'STEP 1' for branch creation, 'WHILE WORKING' for listing and locking, 'WHEN IT LANDS' for removing branches, and 'DAILY SWEEP' for cleanup processes." /></a></p>
<p><em>The whole lifecycle before the per-script detail. Create, inspect while working, remove when it lands, sweep the merged trees on a schedule.</em></p>
<p><strong><code class="language-plaintext highlighter-rouge">_lib.sh</code></strong> is the shared library the others source. It holds the functions that keep conventions consistent: <code class="language-plaintext highlighter-rouge">wt_repo_root</code> resolves the primary checkout even when you call it from inside a linked worktree (it reads <code class="language-plaintext highlighter-rouge">git rev-parse --git-common-dir</code> and walks up), <code class="language-plaintext highlighter-rouge">wt_sanitize</code> lowercases a task id and strips it to <code class="language-plaintext highlighter-rouge">[a-z0-9._-]</code>, <code class="language-plaintext highlighter-rouge">wt_resolve_base</code> fetches and prefers <code class="language-plaintext highlighter-rouge">origin/<base></code> over a local branch, <code class="language-plaintext highlighter-rouge">wt_port_for</code> hashes a branch name into a stable dev port, and <code class="language-plaintext highlighter-rouge">wt_is_locked</code> reads the porcelain worktree list to check lock state. Nothing in it is clever. It exists so the clever bits are written once.</p>
<p><strong><code class="language-plaintext highlighter-rouge">wt-create.sh <task-id> [base]</code></strong> is the one agents call first. It creates the worktree and makes it ready to work in, in one shot:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scripts/worktrees/wt-create.sh add-list-webhooks
</code></pre></div></div>
<p>Under the hood it resolves the base ref (default <code class="language-plaintext highlighter-rouge">develop</code>), ensures <code class="language-plaintext highlighter-rouge">.trees/</code> is in <code class="language-plaintext highlighter-rouge">.gitignore</code>, turns on <code class="language-plaintext highlighter-rouge">git rerere</code> so repeated conflict resolutions replay across parallel merges, runs <code class="language-plaintext highlighter-rouge">git worktree add -b agent/<slug> .trees/<slug> origin/<base></code>, copies the root <code class="language-plaintext highlighter-rouge">.env.local</code> into the worktree, appends a deterministic <code class="language-plaintext highlighter-rouge">DEV_PORT</code> derived from the branch name, runs <code class="language-plaintext highlighter-rouge">npm ci --prefer-offline</code>, and finally locks the worktree so other sessions can see it is in use. Flags let you opt out where it makes sense: <code class="language-plaintext highlighter-rouge">--no-install</code> skips the dependency install for a quick plumbing check, <code class="language-plaintext highlighter-rouge">--no-lock</code> leaves it unlocked, and <code class="language-plaintext highlighter-rouge">--baseline</code> runs the test suite right after setup so a green baseline proves any later failure came from the agent’s change rather than a pre-existing break.</p>
<p>The port assignment is worth a sentence. Two agents both running <code class="language-plaintext highlighter-rouge">next dev</code> on 3000 is another collision, a quieter one. <code class="language-plaintext highlighter-rouge">wt_port_for</code> runs the branch name through <code class="language-plaintext highlighter-rouge">cksum</code> and maps it into the 3100 to 9998 range, so each worktree gets a stable, distinct port written into its own <code class="language-plaintext highlighter-rouge">.env.local</code>. Start the server with <code class="language-plaintext highlighter-rouge">npm run dev -- -p "$DEV_PORT"</code> and two dev servers coexist.</p>
<p><strong><code class="language-plaintext highlighter-rouge">wt-list.sh</code></strong> answers “what is running right now.” It prunes stale metadata, then prints one row per worktree with path, branch, lock state, and short HEAD:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PATH BRANCH LOCKED HEAD
/Users/adron/Codez/interlinedlist feature/fix-blog-image - 58afa91562
/Users/adron/Codez/interlinedlist/.trees/add-list-webhooks agent/add-list-webhooks yes b78faf9e21
</code></pre></div></div>
<p>Before an agent touches a shared file, it can look here and see who else is holding what. Git will not warn you about two branches editing the same file, so this list plus discipline about non-overlapping file domains is the actual safety mechanism.</p>
<p><strong><code class="language-plaintext highlighter-rouge">wt-lock.sh <task-id> [reason]</code></strong> and <strong><code class="language-plaintext highlighter-rouge">wt-unlock.sh <task-id></code></strong> are thin wrappers over <code class="language-plaintext highlighter-rouge">git worktree lock</code>/<code class="language-plaintext highlighter-rouge">unlock</code>. A lock is advisory: it resists prune and move, and it is the signal in <code class="language-plaintext highlighter-rouge">wt-list.sh</code> that says “an agent is live in here, do not reap this.”</p>
<p><strong><code class="language-plaintext highlighter-rouge">wt-remove.sh <task-id></code></strong> is the teardown. It unlocks if needed, runs <code class="language-plaintext highlighter-rouge">git worktree remove</code>, and prunes. This matters more than it looks: deleting a worktree with <code class="language-plaintext highlighter-rouge">rm -rf</code> leaves dangling metadata in <code class="language-plaintext highlighter-rouge">.git/worktrees/</code> that haunts you until the next prune. The script never does that. <code class="language-plaintext highlighter-rouge">--force</code> discards uncommitted changes on purpose, and <code class="language-plaintext highlighter-rouge">--delete-branch</code> drops <code class="language-plaintext highlighter-rouge">agent/<task-id></code> in the same step when the work has landed.</p>
<p><strong><code class="language-plaintext highlighter-rouge">wt-cleanup.sh [base]</code></strong> is the bulk sweep. It walks every worktree physically under <code class="language-plaintext highlighter-rouge">.trees/</code>, and for each one whose branch is already an ancestor of <code class="language-plaintext highlighter-rouge">origin/develop</code> (in other words, merged), it removes the worktree and deletes the branch. It deliberately skips anything outside <code class="language-plaintext highlighter-rouge">.trees/</code>, so the primary checkout and any sibling worktrees I keep elsewhere on disk are never touched. This is the script you point a daily cron or a post-merge hook at.</p>
<p>Each of these is also exposed as an npm script, so <code class="language-plaintext highlighter-rouge">npm run wt:create -- add-list-webhooks</code>, <code class="language-plaintext highlighter-rouge">npm run wt:list</code>, and <code class="language-plaintext highlighter-rouge">npm run wt:remove -- add-list-webhooks</code> all work if you prefer that entry point. There is a <code class="language-plaintext highlighter-rouge">scripts/worktrees/README.md</code> documenting the whole set alongside the code.</p>
<h2 id="wiring-it-into-the-agents">Wiring it into the agents</h2>
<p>Scripts nobody runs are decoration. The point was to make every agent reach for a worktree by default, so I added the instruction in three places at three levels of specificity.</p>
<p>At the top, <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> now states the standing rule: every agent works in an isolated worktree, torn down with the lifecycle scripts. That is the repo-wide contract.</p>
<p>In the middle, two shared protocol docs under <code class="language-plaintext highlighter-rouge">.claude/workflows/</code> hold the full detail: <code class="language-plaintext highlighter-rouge">worktrees.md</code> spells out the create-work-remove lifecycle, the boundaries (shared object store, non-overlapping files, and the important caveat that the database is shared even though the files are not), and <code class="language-plaintext highlighter-rouge">plan-first.md</code> covers the companion habit I wrote about separately. Every agent and skill links to these rather than repeating them.</p>
<p>At the leaf, each of the eight agent definitions in <code class="language-plaintext highlighter-rouge">.claude/agents/</code> got a “Work in an isolated git worktree (required)” section written for its job. The implementers (Next.js, migrations, tests, docs, blog) get the full create-work-remove flow. The two read-only reviewers (security, UX) get a variant that tells them to <code class="language-plaintext highlighter-rouge">cd</code> into the worktree under review and read its diff, and explicitly not to create, lock, or remove anything. The migrations agent gets an extra warning in bold, because the worktree isolates <code class="language-plaintext highlighter-rouge">schema.prisma</code> and the migration files but not the Postgres instance: <code class="language-plaintext highlighter-rouge">db:migrate</code> still hits localhost and <code class="language-plaintext highlighter-rouge">db:migrate:deploy</code> still hits production from any worktree. That is the one place the isolation is a lie, and the agent needs to know it.</p>
<p>The five paired skills in <code class="language-plaintext highlighter-rouge">.claude/skills/</code> (the ones that back the implementer agents) got a short “Worktree-first, plan-first” block near the top pointing at the same protocol docs, so whether the work comes in through the agent or the skill, the instruction is there.</p>
<h2 id="proving-it-works">Proving it works</h2>
<p>I ran the whole lifecycle before committing any of it. Create a worktree from <code class="language-plaintext highlighter-rouge">develop</code>, confirm it is locked and has its port, unlock and re-lock it, remove it with the branch, and verify the cleanup pass leaves the sibling worktrees alone:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scripts/worktrees/wt-create.sh wt-smoke-test --no-install
scripts/worktrees/wt-list.sh # shows agent/wt-smoke-test, LOCKED yes
scripts/worktrees/wt-unlock.sh wt-smoke-test
scripts/worktrees/wt-lock.sh wt-smoke-test "smoke re-lock"
scripts/worktrees/wt-remove.sh wt-smoke-test --delete-branch
scripts/worktrees/wt-cleanup.sh develop # safe no-op, siblings untouched
</code></pre></div></div>
<p>Every step did what it said, the <code class="language-plaintext highlighter-rouge">.gitignore</code> guard refused to duplicate the <code class="language-plaintext highlighter-rouge">.trees/</code> line it found already present, and the sibling worktree I keep for feed-perf work was never in scope for cleanup. That last part was the thing I most wanted to confirm, because a cleanup script that reaches outside its sandbox is worse than no cleanup script.</p>
<h2 id="where-agents-still-get-confused">Where agents still get confused</h2>
<p>Isolating the filesystem fixes the filesystem. It does nothing about the fact that the work itself overlaps, and there are a handful of ways an agent still gets lost.</p>
<p><strong>The database is one instance, and every worktree writes to it.</strong> I flagged this to the migrations agent in bold, but it deserves more than a warning. If the migrations agent adds a column on <code class="language-plaintext highlighter-rouge">agent/add-webhooks</code>, that column now exists in the same localhost Postgres every other worktree points at. The docs agent three trees over never sees the changed <code class="language-plaintext highlighter-rouge">schema.prisma</code>, yet its queries hit the mutated database anyway. Additive-only migrations keep this survivable most of the time, since an extra column nobody reads is harmless. But the moment two agents touch the same table, or one runs <code class="language-plaintext highlighter-rouge">db:migrate:deploy</code> and reaches production from what looked like a sandbox, the isolation is a fiction. The files are private. The database is not.</p>
<p><strong>Green in isolation, red on merge.</strong> This is the one that bites. Agent A changes a function signature in <code class="language-plaintext highlighter-rouge">lib/lists/queries.ts</code>. Agent B, on its own branch, calls that function from a route it owns. Their files never overlap, so <code class="language-plaintext highlighter-rouge">wt-list.sh</code> shows no conflict and git stays quiet. Both test suites pass, because A’s worktree still holds B’s old caller and B’s worktree still holds A’s old signature. It’s all green right up until both branches land on <code class="language-plaintext highlighter-rouge">develop</code>, and then the integration is broken in a way neither agent could see from inside its own tree. Worktrees convert a loud, immediate collision into a quiet one that surfaces later. Often a fair trade, but a trade.</p>
<p><strong>Stale base drift.</strong> Every worktree is cut from <code class="language-plaintext highlighter-rouge">origin/develop</code> the moment it’s created. Agents aren’t always short-lived. Let one run for a few hours while three others merge back, and it’s now building on a <code class="language-plaintext highlighter-rouge">develop</code> that no longer exists. It will reintroduce a helper that got deleted upstream, or write against an API another agent already reshaped. The worktree has no idea the ground moved under it. Nothing in the create-work-remove loop forces a re-fetch, so a long-running agent drifts out of date without noticing.</p>
<p><strong>“Does this already exist?” stops having one answer.</strong> With five branches in flight, whether feature X is “already built” depends on which tree you grep. An agent that checks the primary checkout won’t see work-in-progress on another branch, and it will happily build a second copy. I’ve watched two sessions independently implement overlapping halves of the same feature, each sure it was first, because neither branch was visible to the other and neither said up front what it was about to touch. <code class="language-plaintext highlighter-rouge">wt-list.sh</code> tells you which branches exist. It says nothing about what each one intends to change.</p>
<p><strong>The lock is a suggestion.</strong> <code class="language-plaintext highlighter-rouge">wt-lock</code> resists prune and move, and it flags a tree as live in the list, but it will not stop another agent from opening the same file on its own branch and editing away. The real guard against two agents clobbering one file is the up-front decomposition plus the discipline to run <code class="language-plaintext highlighter-rouge">wt-list.sh</code> and honor what it shows. An agent that skips the check has no seatbelt, just the shape of one.</p>
<p><strong>Abandoned trees pile up.</strong> <code class="language-plaintext highlighter-rouge">wt-cleanup.sh</code> only sweeps branches already merged into <code class="language-plaintext highlighter-rouge">develop</code>. A worktree from a crashed or cancelled session is unmerged, still locked, and invisible to the sweep. It sits on disk with a full <code class="language-plaintext highlighter-rouge">node_modules</code> until someone removes it by hand. And an agent resuming a task can trip over a half-finished tree from an earlier run and read its stale state as current work.</p>
<p><strong>Losing the current directory.</strong> The workflow says <code class="language-plaintext highlighter-rouge">cd .trees/<task-id></code> and do everything there. Shell state doesn’t always survive between tool calls, and an absolute path into the primary checkout looks identical to one into a worktree. An agent that loses track of where it is can read the main checkout’s copy of a file, reason about it as though it were its branch’s version, and edit the wrong tree. The isolation holds only as long as the agent keeps its bearings.</p>
<h2 id="what-this-buys">What this buys</h2>
<p>The honest version: worktrees do not make independent tasks independent. If two agents both need to edit the same route, isolating their filesystems just delays the merge conflict, it does not prevent it. The decomposition still has to be real. What the pattern removes is the accidental collision, the kind that has nothing to do with the work and everything to do with sharing one mutable checkout. Those were most of my pain, and now they are gone by construction.</p>
<h2 id="advantages-and-disadvantages">Advantages and disadvantages</h2>
<p><strong>Advantages</strong></p>
<ul>
<li>Accidental collisions disappear. Each agent gets its own files, index, and <code class="language-plaintext highlighter-rouge">HEAD</code>, so index-lock flicker, a stray <code class="language-plaintext highlighter-rouge">git stash</code> eating another session’s edits, and checkout thrash stop happening.</li>
<li>Every task lands on its own <code class="language-plaintext highlighter-rouge">agent/<task-id></code> branch, which keeps review and merge clean and stops one task’s half-finished mess from bleeding into another’s diff.</li>
<li>The object store is shared, so the costly part of the repo lives on disk once and spinning up another tree is cheap.</li>
<li>Each worktree gets a deterministic <code class="language-plaintext highlighter-rouge">DEV_PORT</code>, so several <code class="language-plaintext highlighter-rouge">next dev</code> servers run side by side instead of fighting over 3000.</li>
<li>The lifecycle is scripted and wired into every agent definition, so the right setup and teardown happen without anyone remembering the incantation.</li>
<li><code class="language-plaintext highlighter-rouge">git rerere</code> is on by default, so a conflict you resolve once replays across the parallel merges that hit it again.</li>
<li>Cleanup is fenced to <code class="language-plaintext highlighter-rouge">.trees/</code>, so the bulk sweep never reaches the primary checkout or the sibling worktrees I keep elsewhere.</li>
</ul>
<p><strong>Disadvantages</strong></p>
<ul>
<li>The database isn’t isolated, and neither is anything else global (production through <code class="language-plaintext highlighter-rouge">db:migrate:deploy</code>, OAuth apps, third-party rate limits). File isolation quietly implies an isolation that isn’t there.</li>
<li>Overlapping edits on separate branches turn into silent, deferred conflicts and semantic breakage that pass every isolated test and only show up at integration.</li>
<li>A long-running worktree drifts from a moving <code class="language-plaintext highlighter-rouge">develop</code>, and nothing in the loop forces the re-fetch that would catch it up.</li>
<li>Locks are advisory, so the actual protection against two agents editing one file is decomposition plus discipline, not anything git enforces.</li>
<li>With several branches live, “does this already exist” has no single answer, and agents duplicate each other’s work when branches can’t see one another.</li>
<li>Every worktree carries a full <code class="language-plaintext highlighter-rouge">node_modules</code>, so disk use and <code class="language-plaintext highlighter-rouge">npm ci</code> time multiply with each active task.</li>
<li>Crashed or abandoned sessions leave locked, unmerged trees that the merged-only cleanup won’t reap, so someone clears them by hand.</li>
<li>Coordination is still manual: the tooling shows which branches exist, not which files each agent means to touch.</li>
</ul>
<p>The next habit I want in every agent sits upstream of all of this: stop and plan before touching a single file. That one is worth its own post.</p>
<p>More solutions to the confusion above are coming in the next few posts. Subscribe so you don’t miss ’em: drop your email into the box just below this post, or grab the <a href="https://interlinedlist.com/blog/rss.xml">RSS feed</a>.</p>
<p><em>I’m <a href="https://interlinedlist.com/user/adron">Adron</a>, brainstorming and building <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
A Research Agent on a Budget Leash, and How I’d Sequence the Whole Build
https://compositecode.blog/2026/08/18/a-research-agent-on-a-budget-leash-and-how-id-sequence-the-whole-build/
2026-08-18T06:30:00-07:00
2026-08-10T23:23:10-07:00
Part 8 emphasizes building a supervised AI agent within a structured workflow using Temporal, focusing on retrieval, reasoning, and drafting without overspending on model tokens. The author outlines the importance of sequencing the development, integrating guardrails like budget limits, and asserts that trust in the agent comes from structured testing and supervision, not vague prompts.
<p><strong>Part 8 of 8: Durable AI for InterlinedList</strong></p>
<hr />
<p>Back in <a href="/wp-content/uploads/2026/08/01-why-temporal-durable-ai-hero-1.png">Part 1</a>, I said the whole series was one argument dressed up as eight posts. LLM tokens are the expensive thing, Temporal is not, and Temporal’s mechanics structurally push the token bill down. I also said the way I’d <em>build</em> it was to brainstorm against the real code first, then hand the agent structure instead of hope. This is where those two threads tie together.</p>
<p>The capstone I keep circling back to is the one that sounds the most like science fiction and is actually the most dangerous to ship carelessly: “research topic X from my saved links and draft a document with citations.” The agent retrieves. It reasons. It drafts. And if I’m not careful, it also burns through a month of my inference budget in one runaway loop while I’m asleep.</p>
<p>So this post is two things. The agent itself, and the playbook for building everything that came before it. The agent is the thing you build last, and the order it sits on top of is the part I actually want to argue for.</p>
<h2 id="the-capstone-is-just-the-earlier-pieces-composed">The Capstone Is Just the Earlier Pieces, Composed</h2>
<p>This part should feel earned by now. The agentic-research-to-draft phase, the last of the proposals, doesn’t invent anything. It <em>composes</em>.</p>
<p>The link content from Part 5 (the crawl-and-cache that turns your saved URLs into readable text) is the agent’s source material. The retrieval from Part 5’s second half, the pgvector search over your own docs, lists, and messages, is how the agent finds the relevant three paragraphs instead of stuffing forty links into context. And the durable generation from Part 3, the workflow that survives a timeout and asks before it writes, is the drafting step.</p>
<p>Compose those three and you get an agent loop: retrieve, reason, retrieve again, draft, cite. Each of those steps is already an activity I’d have built and tested for its own feature. The agent is a workflow that calls them in a loop the model steers.</p>
<p>Which is exactly where it gets scary. A loop the <em>model</em> steers has no natural stopping point. It’ll keep deciding it needs one more search, one more fetch, one more pass at the draft. That’s not a bug in the model. That’s what an agent <em>is</em>. The bug would be letting it run unsupervised.</p>
<h2 id="temporal-supervising-an-llm-agent-is-a-nice-recursion">Temporal Supervising an LLM Agent Is a Nice Recursion</h2>
<p>I like this framing because it’s a little bit funny. Temporal is an orchestrator whose entire job is supervising unreliable, long-running, retry-heavy work. An LLM agent is unreliable, long-running, retry-heavy work. So the agent runs <em>inside</em> a Temporal workflow, and the workflow is the adult in the room.</p>
<p>Three guardrails, all first-class workflow logic, none of them prompt-hope:</p>
<p>A <strong>hard token budget.</strong> Before every model call, the workflow calls <code class="language-plaintext highlighter-rouge">assertBudget</code>, the same primitive from Part 7 that reads the <code class="language-plaintext highlighter-rouge">AiGeneration</code> ledger and throws a non-retryable <code class="language-plaintext highlighter-rouge">BudgetExceeded</code> when the user’s daily or monthly ceiling is hit. The agent can <em>want</em> another search all it likes. If the budget’s gone, the call never fires.</p>
<p>A <strong>max-step cap.</strong> The loop counts its own iterations. Twelve steps, or whatever I tune it to, and it’s done. It drafts with what it has rather than spiraling.</p>
<p>A <strong>wall-clock timeout.</strong> Temporal’s workflow timeout means even a wedged agent eventually stops, cleanly, with whatever it produced so far.</p>
<p>And here’s the piece that makes the whole thing cost-sane instead of terrifying: <strong>every step is memoized.</strong> If the agent fails at step 9 of a 12-step loop (provider hiccup, worker restart, whatever) the workflow <em>resumes</em> at step 9. It does not re-run steps 1 through 8. It does not re-pay for the eight model calls it already made. In a serverless handler, a mid-loop failure means starting the whole expensive agent over. Here it means retrying one step.</p>
<p>Sketching the loop, this is the shape I’d hand Claude to fill in:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Proposed: the supervised agent loop as a Temporal workflow.
export async function researchAgentWorkflow(input: ResearchInput): Promise<Draft> {
const MAX_STEPS = 12;
const context: Snippet[] = [];
for (let step = 0; step < MAX_STEPS; step++) {
// Governor first: a hard ceiling the agent cannot talk past.
await assertBudget(input.userId);
// Cheap model plans the next move over the small retrieved context.
const move = await planNextStep({
model: "claude-haiku-4-5",
goal: input.topic,
context,
});
if (move.type === "done") break;
// Retrieval is mechanical and nearly free. No expensive tier here.
const hits = await retrieveFromLinks(input.userId, move.query);
context.push(...hits); // stays small: top-k snippets, not whole pages
}
// Expensive tier runs exactly once, on the final synthesis.
return await draftWithCitations({
model: "claude-opus-4-8",
topic: input.topic,
context,
});
}
</code></pre></div></div>
<p>The cost story lives in the model choices, not the prose. Retrieval and planning run on a cheap tier: Haiku for the “what should I look for next” decisions, which are frequent and small. Opus 4.8, the expensive tier, runs once, on the final synthesis, over a retrieval-augmented context that stayed deliberately small the whole way through. I’m not paying Opus prices to decide which link to read next. I pay them for the draft, and that’s the one thing in the loop I think is worth it.</p>
<p>No clever prompt is doing that. The structure is.</p>
<h2 id="how-id-sequence-the-whole-build">How I’d Sequence the Whole Build</h2>
<p>If you’ve read the series straight through, you might be tempted to build the shiniest thing first. Don’t. The agent ships <em>last</em>, and the ordering underneath it is the real deliverable of this post, because every step ships value on its own and nothing is a big-bang.</p>
<p>Here’s the order I’d hand myself:</p>
<p><strong>1. The foundation phase: the Temporal worker and the cost primitives.</strong> The worker, the client wiring, the <code class="language-plaintext highlighter-rouge">llmCall</code> activity with the model cascade, <code class="language-plaintext highlighter-rouge">assertBudget</code>, the response cache. This is the groundwork everything else stands on, and nothing works without it. Mostly infra, not code, and it’s a week.</p>
<p><strong>2. The durable publish and idempotency fix, pure win, no LLM.</strong> This is the one from Part 4: the every-minute cron that can double-publish because a partial failure re-runs on the next tick. Rebuild it as a Temporal Schedule with the message id as the workflow id, so a given message can never be published twice. Zero model tokens. It fixes a <em>current</em> production hazard. If I could only ship one thing from this whole series, it’d be this.</p>
<p><strong>3. Durable Generate plus confirm.</strong> Part 3. Long docs stop blowing the function budget, and a wrong list schema never gets silently written because the workflow pauses on a signal and waits for a human. This is the in-product generate button, on solid footing.</p>
<p><strong>4. Link crawl, then retrieval.</strong> Part 5. Crawl the links into a cache (the cheapest, highest-cache-value piece in the set) then layer pgvector retrieval on top. This is “where did I save that article?”</p>
<p><strong>5. Batch enrichment, then prompt-to-list.</strong> Part 6. The fan-out that tags everything and expands anything without re-billing completed calls, then the “build a list from a sentence” flow composed on top of it.</p>
<p><strong>6. The AI calendar and digests.</strong> Part 4’s second half. Now that publishing is durable, the calendar planner and the scheduled digests have solid ground to schedule onto.</p>
<p><strong>7. The cost dashboard, then the agent.</strong> Part 7’s dashboard makes “cheap” <em>visible</em>: tokens, cache-hit rate, budget remaining. I want that governor on-screen <em>before</em> I ship the most token-hungry feature in the whole set. Then, and only then, the agentic capstone.</p>
<p>The rule underneath the whole ordering is that value ships at every step. The publish fix helps users who never touch AI. The link cache is useful before any retrieval sits on it. The dashboard is useful before the agent exists. I’m never in a state where I’ve spent three weeks and have nothing a user can hold.</p>
<h2 id="the-verification-is-where-the-trust-actually-lives">The Verification Is Where the Trust Actually Lives</h2>
<p>The meta-point of the whole series is this one, so I’ll say it flat. You do not trust an autonomous agent because the prompt was good. You trust it because it runs inside guardrails, and because tests prove the properties you care about are there.</p>
<p>So the verification for the agentic capstone, and everything under it, is not an afterthought. It’s the thing that makes any of this shippable:</p>
<p><strong>Vitest unit tests</strong> for the activity logic and the cost math. Does the cascade pick the cheap model for planning? Does <code class="language-plaintext highlighter-rouge">assertBudget</code> throw at the right ledger sum? Pure functions, no DB, fast.</p>
<p><strong>Temporal replay tests</strong> that prove a workflow resumes correctly after a simulated worker crash. This is how I <em>prove</em>, not hope, that a retry doesn’t restart the expensive agent from step zero.</p>
<p><strong>An idempotency test</strong> that starts two publish workflows with the same message id and asserts exactly one publish. That’s the double-post race, closed and locked.</p>
<p><strong>Cost-assertion tests</strong>, the ones I care about most for the agent. Fail an activity mid-pipeline and assert the already-completed model calls are <em>not</em> re-invoked on retry. Memoization is money, and this test is how I know the money’s saved.</p>
<p><strong>Playwright E2E</strong> for the generate to confirm to write flow, so the human-in-the-loop gate works in a real browser and not just in my head.</p>
<p><strong>A security pass</strong> before any PR: SSRF on the crawler (everything through <code class="language-plaintext highlighter-rouge">safeFetch</code>), IDOR on every workflow write (<code class="language-plaintext highlighter-rouge">where: { id, userId }</code>), prompt-injection defaults (artifacts private by default so a poisoned link can’t publish on your behalf), and budget-bypass attempts.</p>
<p>And every schema change (the <code class="language-plaintext highlighter-rouge">LinkContent</code> table, the pgvector column, the response cache) goes through the strict additive migration workflow. Hand-written, idempotent, applied to both databases. No <code class="language-plaintext highlighter-rouge">prisma db push</code>, no shortcuts. That workflow has broken production before, precisely when someone skipped it.</p>
<h2 id="what-i-actually-learned-working-with-claude">What I Actually Learned Working With Claude</h2>
<p>The lesson closes the loop the series opened. Supervise the agent with structure, let the tests carry the trust, and it turns out that’s also how I’ve been building <em>with</em> Claude the whole time.</p>
<p>You don’t hand an agent a vague goal and hope. You hand it constraints: a budget it can’t exceed, a step count it can’t blow past, a timeout it can’t outlast. Then you make it write the tests that prove those constraints hold, the replay test, the idempotency test, the cost-assertion test. What makes the agent trustworthy is the wall around it, not how nicely I phrased the ask.</p>
<p>And that’s true one level up too, which I didn’t expect going in. Sequencing the build so each step is independently verifiable (the publish fix before the calendar, the cache before retrieval, the dashboard before the agent) is the same move as capping the agent’s steps. Give the work boundaries, make the boundaries checkable, prove them before moving on. Whether the thing on the leash is a research agent or my own plan to build one, the leash is what I trust.</p>
<p>I’ve been brainstorming this out loud for eight posts. The pieces are grounded in code that already exists: the <code class="language-plaintext highlighter-rouge">safeFetch</code> guards, the DSL, the crosspost fan-out, the cron that really does risk a double-post. None of it is shipped. All of it is buildable, in the order above, with value at every step. So I’m going to start at step one and build the durable publish fix, because it’s a pure win with no model tokens and it closes a real hazard. I’d love for you to follow along, as I am going to move forward now and implement this. If you’re interested, subscribe!</p>
<p><em><a href="https://interlinedlist.com/user/adron">Adron</a> brainstorming and working on <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
Making LLM Features Cheap Enough to Actually Ship
https://compositecode.blog/2026/08/17/making-llm-features-cheap-enough-to-actually-ship/
2026-08-17T09:30:00-07:00
2026-08-10T23:18:16-07:00
In Part 7 of the series, the author emphasizes the significance of durability in AI to minimize costs, particularly focusing on memoization and caching techniques. They outline strategies to prevent unnecessary token expenses during retries and propose a structured approach to optimize budget management before any billing occurs, ensuring efficient usage of resources throughout the AI processing pipeline.
<p><strong>Part 7 of 8: Durable AI for InterlinedList</strong></p>
<hr />
<p>Six posts in, and I’ve been dancing around a number. Every proposal I’ve sketched (durable generate, the calendar planner, the link crawler, batch enrichment) carries a cost, and the expensive part is never Temporal. It’s the tokens. A worker box and a managed Temporal namespace run me on the order of tens of dollars a month (obviously at this time), flat, whether they process ten jobs or ten thousand. The model bill is the one that scales with usage. <em>It’s also the one that decides whether an AI feature is worth shipping or is a slow-motion budget fire</em>.</p>
<p>So this post is the payoff. Durability isn’t just nice, durability is <em>money</em>. The mechanics I keep reaching for aren’t in the plan because they’re clever. They’re there because each one structurally drives the token bill down, and does it in a way I can measure instead of squint at and hope for. Keeping this cheap relative to the Claude and ChatGPT APIs was never a footnote I tacked onto the series. It’s been the point the whole way through.</p>
<p>None of this is live yet. AI assistance in the product is still marked Coming Soon. This is me brainstorming the cost layer I’d build, and how I’d hand it to Claude to build with me.</p>
<h2 id="the-single-biggest-saver-is-not-re-paying-for-work-you-already-did">The Single Biggest Saver Is Not Re-Paying for Work You Already Did</h2>
<p>Start with the one that matters most. Durable memoization.</p>
<p>When a Temporal workflow runs an activity (say, an <code class="language-plaintext highlighter-rouge">llmCall</code> that generates a document section) the result gets persisted the moment it completes. If the workflow later fails and retries, it does not re-run that activity. It replays the history, sees the completed result, and moves on.</p>
<p>Think about what that means on a six-step generation pipeline that dies at step five. Without durability, a retry starts over: steps one through four re-invoke the model, and you pay for all four again just to get back to where you already were. With Temporal, the retry re-runs step five and <em>only</em> step five. Steps one through four are already-billed calls that never happen twice.</p>
<p>That’s the difference between retries as a cost multiplier and retries that are free. On a batch enrichment job that fails at item 47 of 50, you re-bill item 47, not items 1 through 46. I wrote a whole post about that in Part 6, and it’s the same mechanic every proposal here leans on. Memoization is why a flaky provider or a worker restart doesn’t torch the budget.</p>
<h2 id="cheapest-model-first-escalate-only-on-demand">Cheapest Model First, Escalate Only on Demand</h2>
<p>The next lever is refusing to reach for the expensive tier by default. The <code class="language-plaintext highlighter-rouge">llmCall</code> activity would run a <strong>model cascade</strong>: cheapest capable tier first (Haiku-class, Flash-class), escalating only when the task actually needs it.</p>
<p>Most of what these features do is mechanical. Tagging a message. Summarizing a row. Extracting a field. That work does not need a frontier model, and paying frontier rates for it is just lighting money on fire. So the cascade defaults to cheap, and it reserves the Opus-class tier for the thing the user explicitly asked for: long-form synthesis, a research draft, a document they told me they want written well.</p>
<p>Paired with that: per-target <code class="language-plaintext highlighter-rouge">max_tokens</code> ceilings, so a runaway generation can’t balloon. A message caps around 1k, a list around 4k, a document around 16k. The ceiling isn’t a polite suggestion the model is vaguely aware of. The activity enforces it as a hard cap. So you get the cheap tier by default, a small ceiling per target, and the expensive model only shows up when you asked for it.</p>
<h2 id="a-gate-that-throws-before-it-spends">A Gate That Throws Before It Spends</h2>
<p>Cascades and ceilings keep individual calls cheap. But the scenario that scares me is unbounded spend: a runaway agent loop, or a leaked user key someone else is now happily burning. So the plan puts a budget gate <em>before</em> every call, not after.</p>
<p>An <code class="language-plaintext highlighter-rouge">assertBudget</code> activity reads accumulated spend from the <code class="language-plaintext highlighter-rouge">AiGeneration</code> ledger and throws a <strong>non-retryable</strong> <code class="language-plaintext highlighter-rouge">BudgetExceeded</code> when the user is over their ceiling. Non-retryable is the important word. A normal failure retries; this one halts the workflow cold, because retrying a budget breach just tries again to spend money you’ve already said no to.</p>
<p>Roughly what I’d hand to Claude as the shape:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// activity: runs on the worker, before any billable llmCall
export async function assertBudget(userId: string, estimate: TokenEstimate): Promise<void> {
const spent = await getSpendThisPeriod(userId); // counts only, from AiGeneration
const remaining = budgetFor(userId) - spent;
if (estimate.maxTokens > remaining) {
// non-retryable: a budget breach must not loop and re-attempt the spend
throw ApplicationFailure.nonRetryable(
`Budget exceeded: need ${estimate.maxTokens}, have ${remaining}`,
"BudgetExceeded",
);
}
}
</code></pre></div></div>
<p>A workflow calls <code class="language-plaintext highlighter-rouge">assertBudget</code> and then <code class="language-plaintext highlighter-rouge">llmCall</code>, in that order, every time. Worst case is a bounded overspend of one call, never an open tap.</p>
<h2 id="never-bill-the-same-request-twice">Never Bill the Same Request Twice</h2>
<p>Then there’s the plain-dumb-obvious saver: caching. A response cache keyed by <code class="language-plaintext highlighter-rouge">hash(system + prompt + model)</code>. If an identical request comes through (same system prompt, same user prompt, same model) it’s served from cache and never re-billed. Regenerate the same summary twice, pay once.</p>
<p>Same idea one layer down for link work: URL-hash dedup. When the crawler from Part 5 fetches and embeds a link, it keys on a hash of the URL. A link shared across ten messages, three docs, and a list row gets fetched, extracted, and embedded exactly once, ever. Embeddings are already orders of magnitude cheaper than generation, so computing them once and caching forever makes the cheapest part of the pipeline round down to nothing.</p>
<p>And to keep from wasting tokens on <em>retries you caused yourself</em>: worker concurrency and task-queue rate caps tuned to stay under provider limits. Blow past a rate limit and you get a 429 storm, and every retry in that storm is wall-clock and, on some providers, tokens down the drain. Obeying the limit is cheaper than fighting it.</p>
<p>One more, because it’s the most satisfying: confirm-before-spend. For the prompt-to-list flow, the workflow generates the schema, then pauses on a Signal and waits. It does not spend a single row-generation token until the user confirms the schema is right. Get the schema wrong and you’ve spent one cheap schema call, not fifty row calls against a shape nobody wanted. That pause is impossible in a serverless handler and native in Temporal.</p>
<h2 id="the-ledger-counts-it-doesnt-read">The Ledger Counts, It Doesn’t Read</h2>
<p>Every one of these levers needs a source of truth, and that’s the <code class="language-plaintext highlighter-rouge">AiGeneration</code> ledger. Critical design decision: it records <strong>counts and status only</strong>. Tokens in, tokens out, which model, succeeded or failed. It never stores the prompt text or the model output.</p>
<p>That’s not laziness. One table does three jobs. Privacy, because I’m not warehousing what people wrote or what the model said back. The quota counter, because <code class="language-plaintext highlighter-rouge">assertBudget</code> reads its sums off it. And the cost dashboard, because those same rows drive a per-user “AI usage and spend” view: tokens, calls, cache-hit rate, budget remaining. When something looks expensive, the ledger doubles as the debugging trail. You can see <em>that</em> a workflow made forty calls without ever seeing <em>what</em> it said.</p>
<h2 id="build-the-guardrails-with-claude-then-make-it-prove-them">Build the Guardrails With Claude, Then Make It Prove Them</h2>
<p>Now the part I’ve been wanting to get to, because this is where working with an LLM changed how I’d approach any of it.</p>
<p>Memoization and caching only save money if they’re in the <em>right places</em>. Put a cache breakpoint one step too early and you re-bill everything after it. Miss a memoization boundary and a retry quietly re-runs a paid call. The savings are entirely a function of where the boundaries land, and eyeballing a six-step pipeline for those boundaries is the kind of thing I get wrong.</p>
<p>So the first move is a diagnostic question, not a code request. I’d point Claude at the actual workflow and ask: <em>“walk this pipeline and tell me exactly where a retry would re-bill the model.”</em> Then let its answer drive placement. It reads the activity boundaries, traces the retry path, and tells me which calls are already durably memoized and which ones a mid-pipeline failure would re-invoke. That answer is the map. The caching and memoization go where the map says, not where I guessed.</p>
<p>The second move matters more: make it prove the savings. Memoization you can’t prove is just a hope with good intentions. So I’d have Claude write a Temporal replay test (using <code class="language-plaintext highlighter-rouge">TestWorkflowEnvironment</code>) that fails an activity partway through the pipeline and then <em>asserts the already-completed LLM activities are not invoked again on retry</em>.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>it("does not re-bill completed LLM activities on retry", async () => {
const llmCall = vi.fn()
.mockResolvedValueOnce("step-1 result")
.mockResolvedValueOnce("step-2 result")
.mockRejectedValueOnce(new Error("provider blip at step 3")) // fail mid-pipeline
.mockResolvedValue("step-3 result (retry)");
await worker.runUntil(client.workflow.execute(generatePipeline, { /* ... */ }));
// steps 1 and 2 completed before the failure, so Temporal replays their
// memoized results and MUST NOT call the model for them again.
const stepsCalled = llmCall.mock.calls.map((c) => c[0].step);
expect(stepsCalled.filter((s) => s === 1)).toHaveLength(1);
expect(stepsCalled.filter((s) => s === 2)).toHaveLength(1);
expect(stepsCalled.filter((s) => s === 3)).toHaveLength(2); // failed once, retried
});
</code></pre></div></div>
<p>That test is the proof. It fails loudly the day someone refactors the pipeline in a way that breaks memoization and starts silently re-billing steps one and two. The savings stop being a story I tell about the architecture and start being a property CI enforces. Same loop for the response cache: a test that fires two identical requests and asserts the second one hits cache and never reaches the model.</p>
<p>Ask the agent where the money leaks. Let its answer place the guardrails. Then make it write the test that locks them in. That loop works because a cost regression is invisible until the bill shows up weeks later, and a test drags it into the light on the exact commit that caused it, while you can still <code class="language-plaintext highlighter-rouge">git blame</code> your way back to it.</p>
<h2 id="where-this-lands">Where This Lands</h2>
<p>Part 8 is the capstone: the agentic research-to-draft flow, the single most token-hungry thing in the whole set. It’s the reason every governor in this post has to exist before it ships. I’ll also lay out how I’d sequence the entire build, starting from the foundation phase (the Temporal wiring and the shared cost primitives everything else reuses) and working all the way up to the agent, and how I’d verify each piece as it lands.</p>
<p><em><a href="https://interlinedlist.com/user/adron">Adron</a> brainstorming and working on <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
Tag Everything, Expand Anything, Without Re-Billing the Model
https://compositecode.blog/2026/08/16/tag-everything-expand-anything-without-re-billing-the-model/
2026-08-16T09:30:00-07:00
2026-08-10T23:18:39-07:00
Part 6 of 8: Durable AI for InterlinedList Every serverless AI feature I’ve built hits the same wall the second someone wants it at scale. “Can it tag all my untagged messages?” Sure, one message. “All of them?” Now we’re talking about a job that runs for minutes, touches hundreds of items, and will absolutely …
<p><strong>Part 6 of 8: Durable AI for InterlinedList</strong></p>
<hr />
<p>Every serverless AI feature I’ve built hits the same wall the second someone wants it at scale. “Can it tag all my untagged messages?” Sure, one message. “All of them?” Now we’re talking about a job that runs for minutes, touches hundreds of items, and will absolutely fall over partway through. And when it falls over, the naive version starts from zero and re-bills you for every item it already finished. Nobody wrote that behavior on purpose. It’s just what you get by default, and it’s expensive.</p>
<p>This is the post in the <a href="https://github.com/CompositeCode/interlinedlist/blob/develop/docs/plans/blogs/00-series-index.md">Durable AI series</a> where durability pays off the most. Not “nice to have.” It’s the biggest cost saver in the whole series, and I’m putting that up front so you don’t skim past it.</p>
<h2 id="the-jobs-that-dont-fit-in-a-request">The Jobs That Don’t Fit in a Request</h2>
<p>There’s a whole class of work InterlinedList can’t do today and won’t be able to do inside a Vercel function, no matter how the AI plan lands. It’s the bulk stuff:</p>
<ul>
<li>“Add 20 rows to this list matching X.”</li>
<li>“Tag every untagged message.”</li>
<li>“Summarize each finished book in my reading list into its own document.”</li>
<li>“Backfill link previews across all my docs.”</li>
</ul>
<p>What these share is that they’re multi-minute, many-item, and retry-heavy. A serverless handler has a hard duration ceiling and no durable state between invocations. If item 34 of 50 throws a 429 from the model provider, the whole request dies and there’s no clean way to pick up at 35. So you either don’t offer the feature, or you ship a fragile version that quietly loses work and double-charges for the rest.</p>
<p>Today’s ground truth matters here, so let me be precise. Lists are a Subscriber feature. A list’s row data is stored as JSONB in <code class="language-plaintext highlighter-rouge">ListDataRow.rowData</code>, validated against a user-defined schema. The validator that gates every row lives in <code class="language-plaintext highlighter-rouge">lib/lists/dsl-validator.ts</code>. None of the batch AI machinery below exists yet: this is a brainstorm I’d hand to Claude to build, and AI assistance in the product is still “Coming Soon.” I’m designing it out loud.</p>
<h2 id="the-shape-fan-out-cap-concurrency-track-progress">The Shape: Fan Out, Cap Concurrency, Track Progress</h2>
<p>The design I keep landing on is a generic fan-out workflow. One workflow supervises the whole batch; <strong>one item equals one activity.</strong> Each activity is independent and idempotent. It does its work, writes its result, and doesn’t care whether its neighbors succeeded or failed.</p>
<p>Temporal is the durable engine underneath, for reasons that are specific and not hand-wavy:</p>
<p><strong>Partial progress is preserved.</strong> A completed activity’s result is persisted. When the workflow retries after a failure at item 47 of 50, it re-runs item 47 and only item 47. Items 1 through 46 already succeeded and their results are memoized, so it doesn’t touch them. On a batch of 50 LLM calls where one flakes, you re-bill one item, not fifty. This is the money argument, and I like it because it’s structural. You get it from the shape of the thing, not from remembering to code it.</p>
<p><strong>Concurrency is capped.</strong> You don’t fire 50 model calls at once and trigger a 429 storm. A 429 storm is expensive twice over: the retries burn wall-clock, and on some providers they burn tokens too. Cap concurrency to a small number and you respect the provider’s rate limit (and your own BYO-key limit), so the retries never happen at all.</p>
<p><strong>The job survives a worker restart.</strong> Deploy mid-batch, crash the worker, whatever. The workflow resumes from where it was, not from zero.</p>
<p>Here’s the constraint made concrete: a generic <code class="language-plaintext highlighter-rouge">batchEnrich</code> workflow that fans out over items with a bounded concurrency window.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Workflow: supervises the batch, never touches the model or the DB directly.
export async function batchEnrich(input: BatchEnrichInput): Promise<void> {
const { items, concurrency } = input; // e.g. concurrency = 5
const running: Promise<void>[] = [];
for (const item of items) {
// enrichItem is an ACTIVITY: its result is durably memoized.
// On a workflow retry, already-completed items are NOT re-invoked.
// Temporal replays their recorded results instead of re-billing them.
const p = enrichItemActivity(item).then(() => {
recordProgress(item.id); // drives the live progress UI
});
running.push(p);
// Cap in-flight work so we never exceed the provider / BYO-key rate limit.
if (running.length >= concurrency) {
await Promise.race(running);
// prune settled promises so the window stays at `concurrency`
}
}
await Promise.all(running);
}
</code></pre></div></div>
<p>The per-item activity is where the real work (and the idempotency) lives. It reuses code that already ships in the repo:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Activity: one item. Independent, idempotent, individually retryable.
export async function enrichItemActivity(item: EnrichItem): Promise<void> {
// 1. Cheap-model call for the mechanical part (tag / summarize).
const enriched = await llmCall(item.prompt, { model: "cheap" });
// 2. Validate against the list's real schema before writing anything.
// validateFormData lives in lib/lists/dsl-validator.ts, the same gate
// the app uses for every human-entered row.
const result = validateFormData(item.fields, enriched.rowData);
if (!result.isValid) throw new NonRetryableError(result.errors);
// 3. Write through the existing materialize helpers, not raw Prisma.
// lib/materialize/build-list.ts / build-doc.ts already know how to
// turn structured data into rows and documents with ownership intact.
await writeThroughMaterialize(item.target, enriched);
}
</code></pre></div></div>
<p>Nothing here reinvents validation or writing. <code class="language-plaintext highlighter-rouge">validateFormData</code> is the existing row validator. <code class="language-plaintext highlighter-rouge">lib/materialize/build-list.ts</code> and <code class="language-plaintext highlighter-rouge">build-doc.ts</code> are the existing builders that turn structured content into lists and documents. The persistence writers I sketched back in the foundation phase, the groundwork everything in this series stands on, handle the actual writes with IDOR checks intact. The workflow’s only job is orchestration: fan out, cap, track, resume.</p>
<h2 id="the-cost-levers-stacked">The Cost Levers, Stacked</h2>
<p>Cost is the spine running through this whole series, so let me connect the dots. Three levers push the LLM bill down, and this one feature stacks all three:</p>
<p><strong>Batching.</strong> Tagging is mechanical and small. Instead of N calls to tag N messages, one call tags a batch of them: you collapse the per-request overhead and the token duplication of repeating the same system prompt N times over. Fan-out doesn’t have to mean one-model-call-per-item. The fan-out unit can itself be a batch.</p>
<p><strong>A cheap-model default.</strong> Tagging and summarizing don’t need a frontier model. They need a competent, fast, cheap one. Claude Haiku 4.5 runs at $1 per million input tokens and $5 per million output; Opus 4.8 is $5 and $25. That’s a 5x swing for mechanical work where the cheap tier does the job fine. The default model for a batch-tag job should be the cheap one, and you escalate only for the rare task that needs it.</p>
<p><strong>No redo on retry.</strong> Memoization again: the durable engine never re-invokes an already-billed call on a retry. This is the lever that costs you nothing to pull, because it’s baked into how the workflow resumes.</p>
<p>Put those together and a “tag everything” run over a few hundred messages costs cents instead of dollars, and a mid-run failure costs you one retry instead of a full re-run.</p>
<h2 id="what-id-tell-claude-before-it-writes-anything">What I’d Tell Claude Before It Writes Anything</h2>
<p>There’s a “working with Claude” lesson buried in all this, and honestly it’s why I wanted to write this post at all.</p>
<p><strong>Resumability is a design constraint, not a feature you bolt on later.</strong> Ask an agent to “write a batch tagging job” and say nothing else, and you get a <code class="language-plaintext highlighter-rouge">for</code> loop that calls the model N times and stores results at the end. It works in the demo. Then it falls over on the first real batch, loses partial work, and re-bills everything on retry. Now you’re spending a day retrofitting durability into code that was never shaped for it, and durability doesn’t retrofit cleanly, because it changes what a “unit of work” even is.</p>
<p>So I state the constraints before the code, right in the prompt:</p>
<blockquote>
<p>Each item is an independent, idempotent activity. The workflow must resume mid-batch without re-running finished items: a failure at item 47 of 50 re-processes only item 47. Cap concurrency at N to stay under the provider rate limit. Validate every item against the list schema before writing, and write through the existing materialize helpers, not raw Prisma.</p>
</blockquote>
<p>Hand it <em>that</em>, and Claude generates the right thing: the generic <code class="language-plaintext highlighter-rouge">batchEnrich</code> workflow, the per-target activity adapters (list rows go through <code class="language-plaintext highlighter-rouge">build-list</code>, documents through <code class="language-plaintext highlighter-rouge">build-doc</code>, message tags through the tag writer), and the batched prompt that tags many items in one call. The constraints do the architectural work; the agent fills in the wiring. I own the seam (what’s an activity, what’s idempotent, where the concurrency cap lives) and let it own the code.</p>
<p>The failure mode I’m avoiding is the one where the constraint shows up <em>after</em> the code. State “don’t re-bill completed items” up front and it shapes the whole structure. State it after, and you’re rewriting.</p>
<h2 id="the-surface">The Surface</h2>
<p>On the product side this becomes a “run a bulk action” panel: pick a target (a list, your messages, a set of docs), describe the enrichment, and watch live progress as items complete. Cancel is a Temporal Signal, a real mid-flight stop rather than a “please ignore the results” fiction. The workflow receives the signal, stops fanning out new items, lets the in-flight ones finish or abort, and reports what it got done. Progress and cancel both fall out of a workflow that already tracks per-item state.</p>
<p>Part 7 is the payoff post: the cost governor, where all of these levers get formalized into a budget ledger, a model cascade, and the response cache that make “cheap enough to ship” measurable instead of hoped-for.</p>
<p><em><a href="https://interlinedlist.com/user/adron">Adron</a> brainstorming and working on <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
Where Did I Save That Article? Crawl, Cache, Retrieve
https://compositecode.blog/2026/08/15/where-did-i-save-that-article-crawl-cache-retrieve/
2026-08-15T06:30:00-07:00
2026-08-10T23:19:25-07:00
The post explores improvements to an AI tool for managing saved links, addressing challenges in retrieval. It proposes creating a durable cache for links and enhancing search functionality with vector embeddings. The author emphasizes the need for security in URL fetching and systematic database migrations. The goal is easy access to previously saved articles and improved content management.
<p><strong>Part 5 of 8: Durable AI for InterlinedList</strong></p>
<hr />
<p>I have a problem, and I bet you have it too. I read something good, I drop the link into a post or a document or a row in some list, and then three weeks later I want it back and I have no idea where it went. Was it a message? A doc? A row in that reading-list I built? So I scroll my own feed like an archaeologist. It’s a slow-moving disaster. The whole point of writing something down is being able to get it back, and I’d built the writing-down part beautifully while ignoring the getting-it-back part entirely.</p>
<p>So this is the post where I brainstorm fixing that. Two pieces come from <a href="https://github.com/CompositeCode/interlinedlist/blob/develop/docs/plans/temporal-solutions.md">the Temporal plan</a>, and they run in order. First, the link-crawl-and-cache phase turns saved links into a real cache. Then the retrieval phase makes that cache searchable, semantic <em>and</em> keyword, with cited snippets, so “where did I save that article about X” actually returns the article about X. None of this is shipped. AI assistance in the product is still “Coming Soon.” This is me thinking out loud with the intent to build.</p>
<h2 id="what-actually-exists-today-less-than-youd-hope">What Actually Exists Today (Less Than You’d Hope)</h2>
<p>Before I design anything, I made myself write down what the codebase does <em>right now</em>, because designing against a fantasy version of your own product is how you build the wrong thing.</p>
<p>Today, link metadata gets fetched for messages only. When you post something with a URL, <code class="language-plaintext highlighter-rouge">lib/messages/metadata-fetcher.ts</code> pulls the Open Graph tags and the result gets stored inline on the message as a JSONB blob: <code class="language-plaintext highlighter-rouge">Message.linkMetadata</code>. That’s it. There’s no cache table. There’s no dedup, so if you and forty other people all post the same great article, that URL gets fetched forty-one times. Documents and list rows (both Subscriber features) don’t get scanned for links at all. And search? Search today is Postgres <code class="language-plaintext highlighter-rouge">ILIKE</code> substring matching across three separate routes: messages, lists, documents. It’s a <code class="language-plaintext highlighter-rouge">contains</code> query. Type “kubernetes” and you get rows where the literal string “kubernetes” appears. Miss the spelling, miss the synonym, miss the concept, and you get nothing. There is zero vector infrastructure anywhere in the repo. I checked.</p>
<p>That’s the honest starting line. It’s also a decent starting line, because two of the hard parts are already solved and I’d be a fool to rewrite them.</p>
<h2 id="the-crawler-reuse-the-fetcher-reuse-the-guard">The Crawler: Reuse the Fetcher, Reuse the Guard</h2>
<p>So, the caching step. A durable workflow scans a user’s messages, documents, and list rows for URLs, fetches each one, extracts the readable content, and stores it in a new <code class="language-plaintext highlighter-rouge">LinkContent</code> cache table keyed by a hash of the URL. Deduplicated across all your content, and optionally across all users. It runs incrementally: only new or changed URLs get fetched, so the second run is nearly free.</p>
<p>The building blocks already exist. <code class="language-plaintext highlighter-rouge">lib/messages/link-detector.ts</code> gives me <code class="language-plaintext highlighter-rouge">extractUrls</code> and <code class="language-plaintext highlighter-rouge">detectLinks</code>. <code class="language-plaintext highlighter-rouge">lib/messages/metadata-fetcher.ts</code> gives me <code class="language-plaintext highlighter-rouge">fetchLinkMetadata</code>. And critically, <code class="language-plaintext highlighter-rouge">lib/security/ssrf.ts</code> gives me <code class="language-plaintext highlighter-rouge">safeFetch</code> and <code class="language-plaintext highlighter-rouge">assertUrlAllowed</code>, the SSRF guard the codebase already fought to get right. This work is mostly <em>generalizing</em> the message-only path to also cover docs and list rows, then bolting a real cache behind it so a URL is fetched once and never again.</p>
<p>This is the single most important thing to say to Claude when you hand it this task, so I’ll say it here first: <strong>build on <code class="language-plaintext highlighter-rouge">safeFetch</code> and <code class="language-plaintext highlighter-rouge">assertUrlAllowed</code>. Do not write a new <code class="language-plaintext highlighter-rouge">fetch()</code>.</strong></p>
<p>I cannot overstate this. Left to its own devices, an LLM asked to “write a crawler that fetches URLs and extracts content” will cheerfully write you a fresh <code class="language-plaintext highlighter-rouge">fetch(url)</code> with a nice timeout and some error handling, and it’ll look completely reasonable, and it’ll have reintroduced the server-side request forgery hole this codebase already closed. It won’t check whether the URL resolves to <code class="language-plaintext highlighter-rouge">169.254.169.254</code> and hands back your cloud metadata. It won’t block the redirect that bounces from a public host to <code class="language-plaintext highlighter-rouge">localhost</code>. The existing <code class="language-plaintext highlighter-rouge">safeFetch</code> does all of that, and it re-validates on every redirect hop. A brand-new fetcher is a security regression wearing the costume of a feature.</p>
<p>So the instruction isn’t “write a crawler.” The instruction is:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Crawler activity: the reuse contract, spelled out for the agent.
// 1. detectLinks/extractUrls from lib/messages/link-detector.ts to find the URLs
// 2. hash each URL, check the LinkContent cache, skip if already fetched & unchanged
// 3. fetch ONLY through safeFetch from lib/security/ssrf.ts (never a raw fetch)
// 4. extract readable text mechanically (HTML to text), NO LLM in this path
// 5. upsert into LinkContent keyed by urlHash; dedup means fetch-once-ever
async function crawlUserLinks(userId: string) {
const urls = await collectUrlsFromMessagesDocsAndRows(userId); // reuse extractUrls
for (const url of dedupeByHash(urls)) {
if (await cacheHasFresh(url)) continue; // incremental: skip known URLs
const html = await safeFetch(url); // SSRF guard, NOT a new fetch()
await upsertLinkContent(url, extractReadableText(html));
}
}
</code></pre></div></div>
<p>Note what’s <em>not</em> in there: a language model. HTML-to-readable-text is mechanical. You strip the tags, you pull the article body, you’re done, no tokens spent. An optional cheap-model summary of each page could sit on top later, opt-in, for people who want it, but the base extraction costs nothing per link beyond the fetch. Pair that with URL-hash dedup (a shared link is fetched once, ever, across the whole system) and this becomes the cheapest proposal in the whole plan for what you get out of it. Which is why I like it. It’s mostly plumbing, mostly reuse, and it barely touches an LLM.</p>
<h2 id="retrieval-the-part-that-needs-new-ground">Retrieval: The Part That Needs New Ground</h2>
<p>The crawl phase gives me the cache. The search-over-my-links phase is what makes it findable. This is “search over my link index <em>and</em> my own docs, lists, and messages, returning cited snippets.”</p>
<p>And I have to be honest that this one needs real new infrastructure, because the vector story doesn’t exist yet: not partially, not disabled behind a flag, not anywhere. So the retrieval phase needs a <code class="language-plaintext highlighter-rouge">pgvector</code> extension, an embedding column or table, an embed activity that turns text into vectors, and a search route that ranks by both vector similarity <em>and</em> the existing <code class="language-plaintext highlighter-rouge">ILIKE</code> match. Hybrid. It <strong>supplements</strong> today’s substring search rather than ripping it out. Keyword matching is still the right answer for exact-string lookups, and it needs no model at all.</p>
<p>The cost math here is the reassuring part. Embeddings are orders of magnitude cheaper than text generation: you’re paying to turn text into a vector, not to write prose. You compute each embedding once and cache it, sharing the same cache the crawl phase already built. And because retrieval comes <em>first</em>, any eventual answer-synthesis step runs over a small, already-narrowed set of retrieved snippets instead of dumping your whole corpus into a prompt. Keep the expensive context small and this stays affordable.</p>
<p>The incremental pipeline is what keeps it fresh without a nightly full re-scan: on every content write (new post, edited doc, added row) enqueue a re-embed activity for just that thing. Batch the embeddings for throughput. The index stays current, and you never re-embed the 99% of your corpus that didn’t change.</p>
<h2 id="the-second-lesson-dont-let-the-agent-touch-the-database-directly">The Second Lesson: Don’t Let the Agent Touch the Database Directly</h2>
<p>The <code class="language-plaintext highlighter-rouge">pgvector</code> migration is where the <em>other</em> working-with-Claude lesson lives, and it’s a governance one.</p>
<p>This repo has a strict database workflow, and it’s strict for a reason the CLAUDE.md states plainly: violating it has broken production before. Additive-only. Idempotent SQL. Hand-written migration files applied to both databases. No <code class="language-plaintext highlighter-rouge">prisma db push</code>, no <code class="language-plaintext highlighter-rouge">prisma migrate dev</code>, no raw DDL fired at the database from a script.</p>
<p>An LLM does not know this in its bones. Ask it to “add a vector column and an embeddings table” and its instinct (a perfectly good instinct in most repos) is to edit the schema and run <code class="language-plaintext highlighter-rouge">prisma db push</code> to sync it. In <em>this</em> repo that’s a loaded gun. So the instruction to the agent isn’t just “add the schema.” It’s: <strong>route this through the db-migrations workflow. Write an idempotent, additive migration file (<code class="language-plaintext highlighter-rouge">CREATE EXTENSION IF NOT EXISTS vector</code>, <code class="language-plaintext highlighter-rouge">CREATE TABLE IF NOT EXISTS</code>, the column adds guarded) and do not run <code class="language-plaintext highlighter-rouge">db push</code> or <code class="language-plaintext highlighter-rouge">migrate dev</code> against the database.</strong> The good news is the migration is cleanly additive: there’s no existing vector infra to alter or drop, so it’s all new tables and columns, which is exactly the shape this workflow handles best.</p>
<p>Two guardrails, then, and I’d hand both to the agent up front rather than catch them in review: reuse <code class="language-plaintext highlighter-rouge">safeFetch</code> instead of writing a fetcher, and route the schema change through the migration workflow instead of letting it push. Both are cases where the model’s default behavior looks reasonable and is wrong for this codebase. Tell it about the thing that already exists, and tell it not to reinvent it.</p>
<p>The payoff, if I build it, is the thing I wanted in the first place. I type “that article about durable execution I saved somewhere,” and I get it back with the snippet and a link to wherever I stashed it, whether that was a throwaway post or a row buried in a list. My scattered links stop being a graveyard I dig through and turn into something I can actually ask questions of. Subscriber-gated, computed once and cached, and cheap enough that I’d leave it running without watching the bill.</p>
<p>Next up in Part 6, I want to point this same durable-fan-out machinery at bulk work: tag every untagged message, add twenty rows matching a criterion, backfill previews across everything, and do it without re-billing the model for the forty-six items that already succeeded before item forty-seven fell over.</p>
<p><em><a href="https://interlinedlist.com/user/adron">Adron</a> brainstorming and working on <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
The Cron That Double-Posts and the Calendar I Actually Want
https://compositecode.blog/2026/08/14/the-cron-that-double-posts-and-the-calendar-i-actually-want/
2026-08-14T06:30:00-07:00
2026-08-14T09:51:45-07:00
The post discusses a critical bug in a scheduled publishing feature used for cross-posting on various platforms. It details how the current system may lead to double posts due to timing issues between cross-posting and database updates. The author suggests a robust solution involving a new scheduling system to avoid such errors while emphasizing the importance of addressing underlying hazard classes rather than mere symptoms.
<p><strong>Part 4 of 8: Durable AI for InterlinedList</strong></p>
<hr />
<p>Most of this series is me thinking out loud about features that don’t exist yet. This post is different as the bug is real. It’s in the code today. 😬</p>
<p>Scheduled publishing is a Subscriber feature, and so is the cross-posting it rides on. You queue a post, set a time, and a cron fans it out to Bluesky, Mastodon, LinkedIn, and X when the moment arrives. Both sit behind the subscription, so this bug hits paying subscribers. The machinery that runs it has a hole in it I don’t love.</p>
<h2 id="the-cron-that-can-post-twice">The Cron That Can Post Twice</h2>
<p>Walk through what the code does. <code class="language-plaintext highlighter-rouge">app/api/cron/publish-scheduled-messages/route.ts</code> runs every minute. It selects every message where <code class="language-plaintext highlighter-rouge">scheduledAt <= now</code>, then loops over them. For each one it fans out to whatever platforms the user configured, collects the resulting URLs, and then, as the last step, updates the message row to null out <code class="language-plaintext highlighter-rouge">scheduledAt</code> so the message is no longer “due.”</p>
<p>Read that ordering again. The cross-post happens first. The “mark it done” write happens second. No lock. No idempotency key. No transaction spanning the fan-out and the update. Those two operations are just sequential, and hopeful.</p>
<p>So picture the fan-out succeeding. Your post lands on Bluesky and Mastodon. Great. Now the <code class="language-plaintext highlighter-rouge">message.update</code> that’s supposed to clear <code class="language-plaintext highlighter-rouge">scheduledAt</code> hits a reaped Neon connection, or a transient network blip, or any of the ten boring things that make a database write fail once in a while. The update throws. The message row still has <code class="language-plaintext highlighter-rouge">scheduledAt <= now</code>.</p>
<p>One minute later the cron runs again. The message is still due. It fans out <em>again</em>. Same post, second time, to every platform. That’s your double-post.</p>
<p>The code isn’t naive about this: there’s a <code class="language-plaintext highlighter-rouge">withPrismaRetry</code> wrapper on that critical update and a comment that literally says “if this fails the message stays due and is re-published.” Someone saw the cliff and put up a guardrail. But a retry is a mitigation, not a fix. It narrows the window without closing it. If the retries exhaust, or the process dies between the fan-out and the update, you’re back on the cliff. And the fan-out itself is best-effort per platform (LinkedIn can fail while Bluesky succeeds, with no compensation), so a re-run doesn’t even cleanly re-do the same thing. It re-does <em>some</em> of it.</p>
<p>This is a slow-moving disaster. It doesn’t fire on every post. It fires on the unlucky ones, occasionally, in production, and it makes your platform look like it can’t be trusted to post your thing exactly once.</p>
<h2 id="the-working-with-claude-lesson-point-at-the-bug-class-not-the-bug">The Working-With-Claude Lesson: Point at the Bug Class, Not the Bug</h2>
<p>If you take one thing from this post, take this part, not the fix. It’s worth more.</p>
<p>If I open a session and say “fix the double-post in publish-scheduled-messages,” I get a patch that treats the symptom. Maybe it wraps more things in retries. Maybe it adds a <code class="language-plaintext highlighter-rouge">published</code> boolean and checks it. Narrow prompt, narrow fix, and I’ve probably left three other copies of the same hazard sitting in the codebase untouched.</p>
<p>So I don’t open it that way. I say: <strong>“Audit this scheduled-publish path for concurrency and idempotency hazards.”</strong></p>
<p>That framing is doing real work. It doesn’t presume I already know what’s wrong. It asks the agent to reason about the <em>class</em> of failure: what happens under a partial failure, what happens if two invocations overlap, what’s the ordering of side effects versus state writes, where’s the operation that isn’t idempotent. The double-publish race falls out of that audit as one instance of a general pattern (side effect before commit, no dedup key). And the audit usually surfaces siblings I hadn’t thought to ask about.</p>
<p>This is the two-step I keep coming back to with Claude on anything gnarly. <strong>Audit for the class, then delegate the conversion.</strong> First get the agent to characterize the whole shape of the problem. Then, once you both understand it, hand over the mechanical rewrite. Collapse those into one “fix it” prompt and you’ll get a bandage every time.</p>
<p>Most of the value is in the audit. The conversion is mostly typing, and Claude is very good at the typing once you’ve done the thinking with it.</p>
<h2 id="the-fix-a-schedule-a-workflow-id-and-a-saga">The Fix: A Schedule, a Workflow ID, and a Saga</h2>
<p>So this is the conversion I’d delegate. It’s a proposal. None of it is built yet.</p>
<p>Replace the every-minute cron with a Temporal Schedule that starts <strong>one workflow per due message</strong>. The whole trick is what you use for the workflow ID.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Proposed: the message id IS the workflow id.
// Temporal refuses to start a second workflow with an id that's
// already running or already completed. That's the dedup.
await client.workflow.start(publishScheduledMessage, {
workflowId: `publish-message:${message.id}`,
taskQueue: "publishing",
args: [{ messageId: message.id }],
});
</code></pre></div></div>
<p>Temporal deduplicates by workflow ID. If a workflow with <code class="language-plaintext highlighter-rouge">publish-message:abc123</code> has already run (or is running right now), a second <code class="language-plaintext highlighter-rouge">start</code> with that same ID is rejected or returns the existing handle, depending on the reuse policy you pick. The message <em>cannot</em> be published twice, because you cannot start its publish workflow twice. The race doesn’t get narrower. It closes. There’s no second racer allowed on the track, so there’s nothing left to race.</p>
<p>That’s a different guarantee than “we retried hard enough that it usually works.” One is a hope with good odds. The other is impossible by construction, and I’ll take impossible.</p>
<p>Then model the fan-out as a <strong>saga</strong> instead of a hopeful loop. One activity per platform: <code class="language-plaintext highlighter-rouge">postToBluesky</code>, <code class="language-plaintext highlighter-rouge">postToMastodon</code>, <code class="language-plaintext highlighter-rouge">postToLinkedIn</code>, <code class="language-plaintext highlighter-rouge">postToTwitter</code>, the exact <code class="language-plaintext highlighter-rouge">post-status.ts</code> functions the cron already calls, plus the same <code class="language-plaintext highlighter-rouge">splitTextForPlatform</code> and <code class="language-plaintext highlighter-rouge">resolveLinkedInTarget</code> logic. Each activity retries independently with backoff, and each one’s success is durably recorded before the next runs. If LinkedIn fails after Bluesky succeeded, the workflow <em>knows</em> Bluesky is done and won’t re-do it. It records the half-failure coherently and runs compensation, instead of silently re-posting the whole set on a blind re-run.</p>
<p>And the thing that makes this an easy call: <strong>publishing uses zero LLM.</strong> No model in this loop at all. No tokens, no inference cost, nothing to govern with a budget. It’s pure durability engineering, a straight win against a present-day production hazard with no ongoing cost attached. If anything in this series jumps the queue, it’s this.</p>
<h2 id="the-calendar-i-actually-want">The Calendar I Actually Want</h2>
<p>Once publishing is durable, the thing I’ve wanted for ages becomes buildable. This part is squarely future, gated behind AI assistance that’s still marked Coming Soon.</p>
<p>I want to type “give me a month of posts about distributed systems, three a week” and get a filled calendar back. Not a wizard. Not twelve separate button clicks. A month.</p>
<p>The cheap way to build that leans on batching. <strong>One</strong> LLM call produces all N drafts at once, and batching many items into a single prompt is dramatically cheaper than N separate calls (the cost thesis that runs through this whole series). The workflow takes those drafts, schedules them across the month, and fills the gaps. Then, to keep it cheap, you can reject or regenerate an <strong>individual</strong> slot. Don’t like Thursday’s post? Regenerate just that one. Only the rejected slot re-bills. You never repay for the twelve you kept.</p>
<p>The same shape covers scheduled digests: a Temporal Schedule that drafts you a weekly summary doc from the week’s messages. But put a cheap guard <em>before</em> the model runs. Did this user post anything this week? If not, skip the LLM entirely. You never pay to generate an empty digest for a quiet week. The guard is a database count. The model is the expensive part. You gate the expensive part behind the cheap check, which is the move I keep making all through this series: spend tokens last, and only when there’s something worth spending them on.</p>
<p>The calendar is the feature I daydream about. The double-publish fix is the one I’d ship first, because it’s a real hole in production right now and closing it costs nothing but the work.</p>
<p>Part 5 goes hunting through everything you’ve saved: the links buried in your messages, docs, and list rows, and how I’d crawl and cache them so “where did I save that article?” finally has an answer.</p>
<p><em><a href="https://interlinedlist.com/user/adron">Adron</a> brainstorming and working on <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
A Generate Button That Survives a Timeout and Asks Before It Writes
https://compositecode.blog/2026/08/13/03-a-generate-button-that-survives-a-timeout-and-asks-before-it-writes/
2026-08-13T06:30:00-07:00
2026-08-10T23:20:16-07:00
Part 3 of 8 discusses the development of a durable AI feature for generating lists within an application. The author emphasizes the importance of implementing a two-step process where users confirm outputs before any data is written. This design prevents timeouts and unauthorized data generation, ensuring reliability and user control.
<p><strong>Part 3 of 8: Durable AI for InterlinedList</strong></p>
<hr />
<p>In Part 2 I talked myself into a durable worker sitting next to Vercel, reusing the <code class="language-plaintext highlighter-rouge">lib/</code> code the app already trusts. That was the plumbing. Now for the first feature I’d actually run through it, a Generate button, and the two things I won’t ship it without.</p>
<p>One: it has to survive a timeout. Two: it has to ask before it writes.</p>
<p>I keep calling those hard requirements because they are. They’re the reason this design points at a durable workflow instead of another <code class="language-plaintext highlighter-rouge">POST</code> handler. And to be clear up front, since AI assistance in the product is still marked Coming Soon: none of this exists yet. I’m brainstorming out loud before I hand pieces of it to Claude.</p>
<h2 id="two-ways-a-naive-generate-button-fails">Two Ways a Naive Generate Button Fails</h2>
<p>Picture the obvious version. A route that takes a prompt, calls a model, writes the result. Works great in the demo. Then two things happen.</p>
<p>First, someone asks for a long document. Sixteen thousand tokens of generated Markdown streaming back through a serverless function that has a hard duration ceiling. The function dies mid-stream. The user gets nothing, and depending on where it died, maybe a half-written doc. That’s not a fluke. Any generation big enough to be worth doing is big enough to hit the ceiling.</p>
<p>Second, and this is the one that scares me, the model writes straight into your data. For a plain message, fine, you can delete it. For a <strong>List schema</strong>, that’s a slow-moving disaster. A list’s schema is a set of <code class="language-plaintext highlighter-rouge">ListProperty</code> rows parsed from the DSL, and once rows of data hang off those properties, unwinding a wrong guess gets expensive fast. An AI that invents seven columns and commits them before you’ve looked is a terrible first impression. Undoing a bad schema is the wrong goal. You want to never have written it in the first place.</p>
<p>So the design has to fix both: don’t let a long job die in a function, and don’t write anything the user hasn’t confirmed.</p>
<h2 id="suggest-then-generate-two-endpoints-on-purpose">Suggest, Then Generate: Two Endpoints on Purpose</h2>
<p>The split I keep coming back to is two endpoints, and the difference between them carries the whole safety story.</p>
<p><code class="language-plaintext highlighter-rouge">/suggest</code> returns a validated artifact and writes nothing. <code class="language-plaintext highlighter-rouge">/generate</code> writes.</p>
<p><code class="language-plaintext highlighter-rouge">/suggest</code> is the default path. It produces the thing (a doc draft, a list schema, a set of rows), validates it, and hands it back to the UI for a human to look at. Nothing touches the database. <code class="language-plaintext highlighter-rouge">/generate</code> is the second half, and it only runs after a person confirms.</p>
<p>In the durable version, this isn’t two disconnected calls. It’s one workflow that pauses in the middle. The route starts a workflow and returns an id. The workflow generates the artifact, then <strong>blocks on a Signal</strong> (Temporal’s native “wait for an external event”) until a confirm arrives. Long documents don’t block a function while this happens; the UI polls the workflow and reads progress through a Query. The confirm route (<code class="language-plaintext highlighter-rouge">/generate/[workflowId]/confirm</code>) sends the signal, the workflow wakes up, and <em>then</em> it writes.</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Sketch of the workflow body (not shipped, this is the design I'd hand off).
export async function generateArtifactWorkflow(input: GenerateInput) {
// 1. Draft on a cheap tier. Result is memoized by Temporal.
const artifact = await generateArtifactActivity(input); // Haiku/Flash draft
const validated = await validateArtifactActivity(artifact); // untrusted, checked
// 2. Pause here. No function held open. No tokens spent waiting.
setHandler(confirmSignal, (decision) => { confirmation = decision; });
await condition(() => confirmation !== undefined);
if (confirmation === "reject") return { status: "discarded" };
// 3. Only now do we write, reusing the same lib/ writers the app trusts.
return await writeArtifactActivity(validated, input.userId);
}
</code></pre></div></div>
<p>The part I like: while that workflow is parked on the signal, it costs nothing. No held-open request, no tokens burning, no worker thread stuck. Temporal parks it and moves on. You cannot do that inside a serverless handler, and it’s exactly the behavior a confirm gate needs.</p>
<h2 id="prompt-to-list-is-where-this-really-pays-off">Prompt to List Is Where This Really Pays Off</h2>
<p>The flagship case, the one I’d build the whole pattern around, is “build me a list from a sentence.”</p>
<p>You type <em>“a reading list with title, author, status, and a rating out of five.”</em> The workflow generates a <strong>DSL schema</strong>, not rows. It renders that schema for you to confirm. You look at it, maybe you fix the rating field, you say yes. Only <em>then</em> does it batch-generate rows to populate the list.</p>
<p>Two phases, and the confirm barrier sits between them for a reason: <strong>no row-generation tokens get spent until the schema is confirmed.</strong> If the model guessed the schema wrong, you caught it before paying to fill fifty rows into the wrong shape. So confirm-before-spend does double duty here, better UX and a cost control at once, which is the same thread running through this whole series. The cheapest token is the one you never send.</p>
<p>And I’m not inventing new validation to do this. The DSL already has the pieces. <code class="language-plaintext highlighter-rouge">validateDSLSchema</code> and <code class="language-plaintext highlighter-rouge">parseDSLSchema</code> both live in <code class="language-plaintext highlighter-rouge">lib/lists/dsl-parser.ts</code> today, and they’re what turns DSL into <code class="language-plaintext highlighter-rouge">ListProperty</code> rows for real, human-authored lists right now. The generated schema goes through the exact same door. Then <code class="language-plaintext highlighter-rouge">lib/materialize/build-list.ts</code> builds the actual list. The AI path reuses the trusted path; it doesn’t get a shortcut around it. (Lists are a Subscriber feature, so this whole flow lives behind that gate. The workflow checks it before writing, same as every other list-creating action.)</p>
<h2 id="model-output-is-untrusted-input">Model Output Is Untrusted Input</h2>
<p>One mental flip makes the rest of the design fall out easily. <strong>Model output is untrusted input.</strong> Treat it exactly like a request body from a stranger.</p>
<p>You’d never take a JSON blob off the wire and write it to your database without validating the envelope shape, checking the types, and confirming the user is allowed to do the thing. A model’s output gets the identical treatment: envelope shape first, then DSL validation, then per-row validation, then ownership and subscription gating. Only something that survives all four gates gets written. If the model hallucinates a field type the DSL doesn’t support, validation rejects it the way it’d reject any malformed request. The model doesn’t get trusted just because it’s ours.</p>
<h2 id="how-id-hand-this-to-claude">How I’d Hand This to Claude</h2>
<p>If there’s one working-with-an-LLM lesson in this post, it’s this: <strong>design the contract, delegate the wiring.</strong></p>
<p>There’s a trust boundary running through this feature, and I want to own every inch of it myself. Three pieces I write by hand: the typed artifact envelope (what a generated doc/list/schema/row is <em>shaped</em> like), the validators (envelope, then DSL, then per-row, then gating), and the ownership and subscription checks. That’s where a subtle mistake means a wrong schema gets written or a free user slips past a gate. I don’t delegate that. A plausible-looking version isn’t good enough. I want to have written it.</p>
<p>Everything <em>around</em> that contract is the kind of thing I’d hand to Claude. The workflow body. The signal handler and the <code class="language-plaintext highlighter-rouge">condition</code> wait. The polling glue and the Query that streams progress. The confirm route. It’s durable-execution boilerplate: Claude writes it well, and it’s fast to verify against a contract I already defined.</p>
<p>The hand-off is concrete. I give Claude the typed artifact schema and one rule stated plainly: <em>“validate model output like a request body (envelope, then DSL, then per-row, then gating) and never write it through raw. Here are the validators; call them, don’t reimplement them.”</em> Then I let it build the durable plumbing around that. I own the trust boundary, Claude owns the machinery that carries data across it, and the review stays easy because I already know where the sharp edge is. I made sure it’s mine.</p>
<p>A Generate button that can’t die mid-job and won’t write behind your back is the piece I want first. Next in Part 4, I’ll turn to the cron that already double-posts in production today, and the content calendar I actually want built on top of a scheduler that doesn’t.</p>
<p><em><a href="https://interlinedlist.com/user/adron">Adron</a> brainstorming and working on <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>
Where Does Temporal Even Run When You’re All-In on Vercel?
https://compositecode.blog/2026/08/12/where-does-temporal-even-run-when-youre-all-in-on-vercel/
2026-08-12T06:30:00-07:00
2026-08-12T01:21:47-07:00
The content discusses integrating Durable AI into a serverless application, InterlinedList. The challenge arises from needing persistent components, like Temporal services and workers, which conflict with the serverless model. The solution involves adding one always-on worker to manage tasks while reusing existing codebase and maintaining app structure, ensuring budget control and process integrity.
<p><strong>Part 2 of 8: Durable AI for InterlinedList</strong></p>
<hr />
<p>In <a href="https://github.com/CompositeCode/interlinedlist/blob/develop/blog/i-ve-been-brainstorming-durable-ai">Part 1</a> I talked myself into Temporal as the orchestration layer for the AI features I want to build. Durable memoization, a model cascade, a hard budget gate: all the mechanics that push the LLM bill down. Good story. Then I hit the question that stops most “let’s just add Temporal” plans cold, and it stopped me too.</p>
<p>Where does it actually run?</p>
<p>InterlinedList is 100% serverless on Vercel. Every route is a function that spins up, does its thing, and gets torn down. That model is wonderful right up until you need something that never stops running. And Temporal needs exactly that.</p>
<h2 id="the-two-parts-that-dont-fit">The Two Parts That Don’t Fit</h2>
<p>Temporal has two moving pieces, and neither of them is a serverless function.</p>
<p>The first is the <strong>Temporal Service</strong>, the durable-state backend. It’s the thing that remembers where every workflow is, what’s completed, what’s pending, and what to retry. It’s stateful infrastructure. You don’t invoke it and let it die.</p>
<p>The second is the <strong>Worker</strong>. A worker is a long-lived process that polls a task queue, picks up work, and runs your activities. Polls it continuously. As in, forever. A Vercel function that shuts down after a request cannot be a Temporal worker, because the polling loop dies the instant the function returns. You can’t flag your way out of this in a config file. It’s a straight-up mismatch between “poll a queue for hours” and “respond in seconds then vanish.”</p>
<p>So for a while this felt like a dead end. Adding Temporal seemed to mean un-serverless-ing the whole app, and I did not build InterlinedList on Vercel just to go stand up a fleet of always-on boxes.</p>
<h2 id="one-new-always-on-thing-thats-it">One New Always-On Thing. That’s It.</h2>
<p>The resolution I kept circling back to is smaller than the fear suggested. I introduce <strong>exactly one</strong> new always-on component: a small <code class="language-plaintext highlighter-rouge">@temporalio/worker</code> process running somewhere cheap and boring, like Fly.io, Railway, or a small VM. Something in the five-to-twenty-dollars-a-month range. Alongside it, either self-hosted Temporal OSS or a low-tier Temporal Cloud namespace for the service.</p>
<p>That’s the entire footprint. One worker, one service. Vercel doesn’t change shape at all.</p>
<p>What <em>does</em> change is what the Vercel routes do. They stop doing work and become thin clients. Authenticate with the <code class="language-plaintext highlighter-rouge">getCurrentUser</code> / <code class="language-plaintext highlighter-rouge">getCurrentUserOrSyncToken</code> helpers I already have, gate with <code class="language-plaintext highlighter-rouge">isSubscriber</code>, start a workflow, then hand the UI a workflow id to poll. The route never does the long-running work itself. It just points at it.</p>
<p>The route sketch is almost embarrassingly short:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>export async function POST(request: NextRequest) {
const user = await getCurrentUserOrSyncToken(request);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!isSubscriber(user.customerStatus)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const handle = await client.workflow.start(generateArtifactWorkflow, {
taskQueue: "ai",
workflowId: `gen-${user.id}-${crypto.randomUUID()}`,
args: [{ userId: user.id, prompt }],
});
return NextResponse.json({ workflowId: handle.workflowId });
}
</code></pre></div></div>
<p>Auth, gate, start the workflow, return the id. That 16k-token document generation that would blow right past the function’s duration budget? It never runs <em>in</em> the function. The function kicks it off and gets out of the way.</p>
<h2 id="the-reuse-win-no-second-codebase">The Reuse Win: No Second Codebase</h2>
<p>This is the part that made me stop worrying. The worker is not a new codebase. It imports the same <code class="language-plaintext highlighter-rouge">lib/</code> code the app already runs.</p>
<p>The activities (the actual units of work Temporal executes) call into modules that already exist or are already planned. <code class="language-plaintext highlighter-rouge">safeFetch</code> from <code class="language-plaintext highlighter-rouge">lib/security/ssrf.ts</code> for any link crawling, so the SSRF guards apply exactly as they do today. <code class="language-plaintext highlighter-rouge">detectLinks</code> from <code class="language-plaintext highlighter-rouge">lib/messages/link-detector.ts</code>. The <code class="language-plaintext highlighter-rouge">post-status.ts</code> modules under <code class="language-plaintext highlighter-rouge">lib/bluesky/</code> and <code class="language-plaintext highlighter-rouge">lib/mastodon/</code> for cross-post fan-out. The DSL parser and validator in <code class="language-plaintext highlighter-rouge">lib/lists/dsl-*.ts</code> for anything list-shaped. And the AI writer library from the companion genai plan, <code class="language-plaintext highlighter-rouge">lib/ai/</code>, which doesn’t exist yet but will be wrapped as activities rather than rewritten.</p>
<p>Zero business-logic duplication. That matters more than it sounds. It means the IDOR checks and subscription gating I already trust stay exactly where they live. A worker activity that writes a list row runs through the same <code class="language-plaintext highlighter-rouge">where: { id, userId }</code> ownership filter as the web route. I’m not maintaining two versions of “can this user do this” and praying they stay in sync. There’s one version, and the worker just calls it.</p>
<h2 id="what-the-foundation-actually-contains">What the Foundation Actually Contains</h2>
<p>Before any feature ships, there’s a foundation phase, the groundwork everything else in the plan stands on. It’s the worker, the service, the client wiring, and then a small set of cost-control primitives that every later proposal reuses:</p>
<ul>
<li>An <code class="language-plaintext highlighter-rouge">llmCall</code> activity with a <strong>model-cascade policy</strong> (cheap model first, escalate only when the work demands it) and per-target <code class="language-plaintext highlighter-rouge">max_tokens</code> ceilings: roughly 1k for a message, 4k for a list, 16k for a document.</li>
<li>An <code class="language-plaintext highlighter-rouge">assertBudget</code> activity that reads spend before it lets a call through, so a runaway job throws instead of burning credits.</li>
<li>A <strong>response cache</strong> keyed by <code class="language-plaintext highlighter-rouge">hash(system + prompt + model)</code>, so identical requests never re-bill.</li>
<li>Task queues with <strong>rate limits</strong> tuned to whatever the provider or the user’s BYO key allows.</li>
</ul>
<p>Net-new dependencies are modest: <code class="language-plaintext highlighter-rouge">@temporalio/client</code> on the Vercel side, the <code class="language-plaintext highlighter-rouge">@temporalio/worker</code> / <code class="language-plaintext highlighter-rouge">workflow</code> / <code class="language-plaintext highlighter-rouge">activity</code> trio on the worker host, and one LLM SDK. That’s the whole shopping list. (AI assistance in the product is still Coming Soon. This is me brainstorming the machine that would power it, not shipping it.)</p>
<h2 id="scaffold-the-unfamiliar-own-the-verification">Scaffold the Unfamiliar, Own the Verification</h2>
<p>I’ve been doing a lot of this alongside Claude lately, and one lesson keeps repeating.</p>
<p>A durable-execution worker is new territory for me. When something’s unfamiliar the instinct is either to avoid it or to throw the whole thing at the model and hope. Both are wrong. What works is to <strong>spec the seams and delegate the boilerplate</strong>.</p>
<p>I give Claude the hard constraints, not vibes. “The app is serverless on Vercel and stays that way. Reuse this <code class="language-plaintext highlighter-rouge">lib/</code> code, here are the exact modules. Here are the auth and gating seams. The worker imports, it does not reimplement.” With those rails, Claude is great at generating the worker scaffold and the client wiring, the boilerplate I’d get subtly wrong on the first pass because the framework is new to me.</p>
<p>But I do not let it hand me generated infrastructure and call it done. I own correctness, and for a durable workflow, correctness has a specific proof: a <strong>Temporal replay test</strong> using <code class="language-plaintext highlighter-rouge">TestWorkflowEnvironment</code> that simulates a worker crash mid-workflow and asserts the workflow resumes deterministically, picking up from the last completed activity instead of re-running (and re-billing) the ones already done.</p>
<p>That test does the real work. It’s how “a retry doesn’t re-pay for the LLM call” stops being a claim in a blog post and turns into something I’ve watched go green in CI. The model writes the code I don’t know how to write yet, and I write the test that proves it’s right.</p>
<p>With the foundation standing and provably resumable, Part 3 turns it into the first real feature: a Generate button that survives a timeout and asks before it writes a single thing.</p>
<p><em><a href="https://interlinedlist.com/user/adron">Adron</a> brainstorming and working on <a href="https://interlinedlist.com/">InterlinedList</a>.</em></p>