Toggle Switches When the Thing Behind the Switch Is a Whole System

There is a version of a feature toggle that looks wonderfully simple in a pull request:

if (features.newThing) {
doNewThing();
}

Then newThing 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 if statement is the least interesting part.

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.

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.

1. The order feed: Databricks today, PostgreSQL data API tomorrow

Imagine a partner dropping one immutable CSV file per batch into object storage. Each row is an item on an order:

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

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 same object, validates it, and sends a complete batch to a private data API. The API writes three normalized PostgreSQL tables: customers, orders, and order_items.

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

The switch is a tenant route, 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.

The route and the file contract

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.

// pipeline/route.ts
export type PipelinePath = "databricks" | "postgres";
export interface RouteSnapshot {
version: number;
defaultPath: PipelinePath;
tenants: Record<string, PipelinePath>;
}
export interface BatchRef {
tenantId: string;
batchId: string;
fileName: string; // a basename under the tenant's immutable S3 prefix
sha256: string; // digest of the file's bytes, recorded when uploaded
}
export function choosePath(
config: RouteSnapshot,
tenantId: string,
): { path: PipelinePath; configVersion: number } {
return {
path: config.tenants[tenantId] ?? config.defaultPath,
configVersion: config.version,
};
}
export function checkBatchRef(batch: BatchRef): void {
if (!/^[a-z0-9-]{1,64}$/.test(batch.tenantId) ||
!/^[a-zA-Z0-9-]{1,100}$/.test(batch.batchId) ||
!/^[a-zA-Z0-9-]+\.csv$/.test(batch.fileName) ||
!/^[a-f0-9]{64}$/.test(batch.sha256)) {
throw new Error("Invalid batch reference");
}
}

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: batch-42 with new bytes is an error, not a cute way to overwrite history.

The existing Databricks path

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 COPY INTO 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.

CREATE TABLE IF NOT EXISTS main.orders.order_rows_bronze (
order_id STRING,
customer_id STRING,
customer_name STRING,
sku STRING,
quantity STRING,
event_at STRING
) USING DELTA;
CREATE TABLE IF NOT EXISTS main.orders.order_rows_current (
order_id STRING,
customer_id STRING,
customer_name STRING,
sku STRING,
quantity INT,
event_at TIMESTAMP
) USING DELTA;

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.

// pipeline/databricks.ts
type StatementResult = {
statement_id?: string;
status: { state: string; error?: { message: string } };
result?: { data_array?: string[][] };
};
export class DatabricksSql {
constructor(
private readonly host: string,
private readonly token: string,
private readonly warehouseId: string,
) {}
private async request(path: string, init?: RequestInit): Promise<StatementResult> {
const response = await fetch(`${this.host}${path}`, {
...init,
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
...init?.headers,
},
});
if (!response.ok) throw new Error(`Databricks HTTP ${response.status}`);
return response.json() as Promise<StatementResult>;
}
async execute(statement: string, parameters: { name: string; value: string }[] = []) {
let result = await this.request("/api/2.0/sql/statements", {
method: "POST",
body: JSON.stringify({
warehouse_id: this.warehouseId,
statement,
parameters,
wait_timeout: "10s",
disposition: "INLINE",
format: "JSON_ARRAY",
}),
});
const deadline = Date.now() + 120_000;
while (result.status.state === "PENDING" || result.status.state === "RUNNING") {
if (!result.statement_id || Date.now() > deadline) {
throw new Error("Databricks statement exceeded worker deadline");
}
await new Promise(resolve => setTimeout(resolve, 1_000));
result = await this.request(`/api/2.0/sql/statements/${result.statement_id}`);
}
if (result.status.state !== "SUCCEEDED") {
throw new Error(result.status.error?.message ?? `Statement ${result.status.state}`);
}
return result.result?.data_array ?? [];
}
}
export async function loadDatabricksBatch(
sql: DatabricksSql,
batch: BatchRef,
): Promise<void> {
checkBatchRef(batch);
const prefix = `s3://example-order-feed/${batch.tenantId}/`;
// Only the basename is interpolated; checkBatchRef restricts its alphabet.
await sql.execute(`
COPY INTO main.orders.order_rows_bronze
FROM '${prefix}'
FILEFORMAT = CSV
FILES = ('${batch.fileName}')
FORMAT_OPTIONS ('header' = 'true')
`);
// The source file is a complete order snapshot. Deduplicate source rows
// before MERGE: two matching source rows for one target key are ambiguous.
await sql.execute(`
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)
`);
}

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 order_id must be globally unique for the shown merge.

The PostgreSQL path and its data API

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.

CREATE TABLE customers (
tenant_id text NOT NULL,
customer_id text NOT NULL,
customer_name text NOT NULL,
PRIMARY KEY (tenant_id, customer_id)
);
CREATE TABLE orders (
tenant_id text NOT NULL,
order_id text NOT NULL,
customer_id text NOT NULL,
event_at timestamptz NOT NULL,
PRIMARY KEY (tenant_id, order_id),
FOREIGN KEY (tenant_id, customer_id)
REFERENCES customers (tenant_id, customer_id)
);
CREATE TABLE order_items (
tenant_id text NOT NULL,
order_id text NOT NULL,
sku text NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
PRIMARY KEY (tenant_id, order_id, sku),
FOREIGN KEY (tenant_id, order_id)
REFERENCES orders (tenant_id, order_id) ON DELETE CASCADE
);
CREATE TABLE ingestion_batches (
tenant_id text NOT NULL,
batch_id text NOT NULL,
sha256 char(64) NOT NULL,
committed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, batch_id)
);

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.

The worker downloads the immutable object and parses it. This uses @aws-sdk/client-s3 and csv-parse/sync. 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.

// pipeline/postgres-path.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { parse } from "csv-parse/sync";
import { createHash } from "node:crypto";
export type OrderRow = {
order_id: string;
customer_id: string;
customer_name: string;
sku: string;
quantity: number;
event_at: string;
};
const s3 = new S3Client({});
export async function loadPostgresBatch(
batch: BatchRef,
apiBase: string,
serviceToken: string,
): Promise<void> {
checkBatchRef(batch);
const key = `${batch.tenantId}/${batch.fileName}`;
const object = await s3.send(new GetObjectCommand({
Bucket: "example-order-feed", Key: key,
}));
if (!object.Body) throw new Error("Empty object body");
const bytes = await object.Body.transformToByteArray();
const digest = createHash("sha256").update(bytes).digest("hex");
if (digest !== batch.sha256) throw new Error("Batch content changed");
const records = parse(Buffer.from(bytes), {
columns: true, skip_empty_lines: true, bom: true,
}) as Record<string, string>[];
if (records.length === 0 || records.length > 10_000) {
throw new Error("Batch size outside accepted range");
}
const rows: OrderRow[] = records.map(row => ({
order_id: row.order_id,
customer_id: row.customer_id,
customer_name: row.customer_name,
sku: row.sku,
quantity: Number(row.quantity),
event_at: row.event_at,
}));
const response = await fetch(`${apiBase}/v1/batches`, {
method: "POST",
headers: {
Authorization: `Bearer ${serviceToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ...batch, rows }),
});
if (!response.ok) throw new Error(`Data API rejected batch: ${response.status}`);
}

The API side is where the transaction belongs. zod 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.

// data-api/batches.ts (Fastify + pg + zod)
import Fastify from "fastify";
import pg from "pg";
import { z } from "zod";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const app = Fastify();
const rowSchema = z.object({
order_id: z.string().min(1).max(100),
customer_id: z.string().min(1).max(100),
customer_name: z.string().min(1).max(200),
sku: z.string().min(1).max(100),
quantity: z.number().int().positive(),
event_at: z.string().datetime({ offset: true }),
});
const batchSchema = z.object({
tenantId: z.string().regex(/^[a-z0-9-]{1,64}$/),
batchId: z.string().min(1).max(100),
fileName: z.string().endsWith(".csv"),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
rows: z.array(rowSchema).min(1).max(10_000),
});
app.post("/v1/batches", async (request, reply) => {
const parsed = batchSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "Invalid batch payload" });
}
const input = parsed.data;
// Gateway auth must bind this tenantId to the authenticated caller.
const byOrder = new Map<string, typeof input.rows>();
for (const row of input.rows) {
const group = byOrder.get(row.order_id) ?? [];
group.push(row);
byOrder.set(row.order_id, group);
}
for (const rows of byOrder.values()) {
const first = rows[0];
const skus = new Set<string>();
for (const row of rows) {
if (row.customer_id !== first.customer_id ||
row.customer_name !== first.customer_name ||
row.event_at !== first.event_at || skus.has(row.sku)) {
return reply.code(400).send({ error: "Inconsistent order snapshot" });
}
skus.add(row.sku);
}
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const inserted = await client.query(
`INSERT INTO ingestion_batches (tenant_id, batch_id, sha256)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING batch_id`,
[input.tenantId, input.batchId, input.sha256],
);
if (inserted.rowCount === 0) {
const existing = await client.query(
`SELECT sha256 FROM ingestion_batches
WHERE tenant_id = $1 AND batch_id = $2`,
[input.tenantId, input.batchId],
);
await client.query("COMMIT");
if (existing.rows[0]?.sha256 !== input.sha256) {
return reply.code(409).send({ error: "Batch ID reused with new bytes" });
}
return reply.send({ status: "already-committed" });
}
for (const [orderId, rows] of byOrder) {
const first = rows[0];
await client.query(
`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`,
[input.tenantId, first.customer_id, first.customer_name],
);
await client.query(
`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`,
[input.tenantId, orderId, first.customer_id, first.event_at],
);
const current = await client.query(
`SELECT event_at FROM orders WHERE tenant_id = $1 AND order_id = $2
FOR UPDATE`,
[input.tenantId, orderId],
);
if (new Date(current.rows[0].event_at).toISOString() !==
new Date(first.event_at).toISOString()) continue; // stale snapshot
await client.query(
`DELETE FROM order_items WHERE tenant_id = $1 AND order_id = $2`,
[input.tenantId, orderId],
);
for (const row of rows) {
await client.query(
`INSERT INTO order_items (tenant_id, order_id, sku, quantity)
VALUES ($1, $2, $3, $4)`,
[input.tenantId, orderId, row.sku, row.quantity],
);
}
}
await client.query("COMMIT");
return reply.send({ status: "committed", orders: byOrder.size });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
});

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 FOR UPDATE also serializes replacement of one order’s items. For exact ties in event_at, the upstream contract should supply a monotonically increasing revision; timestamps alone cannot tell which different snapshot wins.

The read half of the data API keeps PostgreSQL behind the boundary too:

app.get<{ Params: { orderId: string } }>(
"/v1/orders/:orderId",
async (request, reply) => {
// tenantId comes from authenticated gateway context, never a query param.
const tenantId = request.headers["x-verified-tenant-id"] as string;
const order = await pool.query(
`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`,
[tenantId, request.params.orderId],
);
if (order.rowCount === 0) return reply.code(404).send();
const items = await pool.query(
`SELECT sku, quantity FROM order_items
WHERE tenant_id = $1 AND order_id = $2 ORDER BY sku`,
[tenantId, request.params.orderId],
);
return { ...order.rows[0], items: items.rows };
},
);

That x-verified-tenant-id 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.

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 tenant_id to the Delta tables and both predicates.

// pipeline/read-order.ts
export type OrderView = {
orderId: string;
customerId: string;
customerName: string;
eventAt: string;
items: { sku: string; quantity: number }[];
};
export async function getOrder(
config: RouteSnapshot,
tenantId: string,
orderId: string,
deps: { databricks: DatabricksSql; apiBase: string; serviceToken: string },
): Promise<OrderView | null> {
if (choosePath(config, tenantId).path === "postgres") {
const response = await fetch(
`${deps.apiBase}/v1/orders/${encodeURIComponent(orderId)}`,
{ headers: { Authorization: `Bearer ${deps.serviceToken}` } },
);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Data API HTTP ${response.status}`);
const row = await response.json() as {
order_id: string; customer_id: string; customer_name: string;
event_at: string; items: { sku: string; quantity: number }[];
};
return {
orderId: row.order_id, customerId: row.customer_id,
customerName: row.customer_name, eventAt: row.event_at,
items: row.items,
};
}
const rows = await deps.databricks.execute(`
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
`, [{ name: "order_id", value: orderId }]);
if (rows.length === 0) return null;
return {
orderId: rows[0][0], customerId: rows[0][1],
customerName: rows[0][2], eventAt: rows[0][5],
items: rows.map(row => ({ sku: row[3], quantity: Number(row[4]) })),
};
}

Finally, the worker uses its pinned route. Read traffic should use the tenant route only after the PostgreSQL side has been backfilled and checked.

// pipeline/worker.ts
export async function processBatch(
config: RouteSnapshot,
batch: BatchRef,
deps: { databricks: DatabricksSql; apiBase: string; serviceToken: string },
) {
checkBatchRef(batch);
const decision = choosePath(config, batch.tenantId);
// Persist {batchId, sha256, path, configVersion} in your job ledger.
if (decision.path === "databricks") {
await loadDatabricksBatch(deps.databricks, batch);
} else {
await loadPostgresBatch(batch, deps.apiBase, deps.serviceToken);
}
return decision;
}

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 writes and reads 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 if statement never tells you.

2. Expedited shipping: a feature flag with a bill attached

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.

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 accountId for the rollout. It is small and portable across TypeScript and Go; it is not a security primitive. A production flag service can own the assignment instead, provided both stacks read the same assignment.

TypeScript implementation

// checkout/expedited.ts
export type ShippingFlag = {
enabled: boolean;
killSwitch: boolean;
rolloutPercent: number; // integer 0..100
revision: string;
};
export type QuoteInput = {
accountId: string;
subtotalCents: number;
region: "US-LOWER-48" | "US-OTHER" | "INTERNATIONAL";
};
export type ShippingQuote = {
standardCents: number;
expeditedCents: number | null;
decision: {
offered: boolean;
flagRevision: string;
reason: string;
};
};
export function bucket(accountId: string): number {
let hash = 0x811c9dc5;
for (const byte of new TextEncoder().encode(accountId)) {
hash ^= byte;
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash % 100;
}
export function quoteShipping(input: QuoteInput, flag: ShippingFlag): ShippingQuote {
if (!Number.isSafeInteger(input.subtotalCents) || input.subtotalCents < 0 ||
!Number.isInteger(flag.rolloutPercent) ||
flag.rolloutPercent < 0 || flag.rolloutPercent > 100) {
throw new Error("Invalid quote input or flag configuration");
}
const reason = !flag.enabled || flag.killSwitch ? "disabled"
: bucket(input.accountId) >= flag.rolloutPercent ? "outside-rollout"
: input.region !== "US-LOWER-48" ? "unsupported-region"
: input.subtotalCents < 7_500 ? "below-minimum"
: "eligible";
return {
standardCents: 799,
expeditedCents: reason === "eligible" ? 1499 : null,
decision: {
offered: reason === "eligible",
flagRevision: flag.revision,
reason,
},
};
}
// At checkout creation, persist the quoted cents and decision with the order.
// At payment confirmation, charge that stored quote after its normal expiry
// and inventory checks. Do not ask the flag again halfway through checkout.

This is where teams often reach for Math.random() 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.

The same boundary in Go

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.

package shipping
import (
"errors"
"hash/fnv"
)
type Flag struct {
Enabled bool
KillSwitch bool
RolloutPercent int
Revision string
}
type Input struct {
AccountID string
SubtotalCents int64
Region string
}
type Decision struct {
Offered bool
FlagRevision string
Reason string
}
type Quote struct {
StandardCents int64
ExpeditedCents *int64
Decision Decision
}
func Bucket(accountID string) int {
h := fnv.New32a()
_, _ = h.Write([]byte(accountID)) // UTF-8 bytes, as in TextEncoder
return int(h.Sum32() % 100)
}
func QuoteShipping(in Input, flag Flag) (Quote, error) {
if in.SubtotalCents < 0 || flag.RolloutPercent < 0 ||
flag.RolloutPercent > 100 {
return Quote{}, errors.New("invalid quote input or flag configuration")
}
reason := "eligible"
switch {
case !flag.Enabled || flag.KillSwitch:
reason = "disabled"
case Bucket(in.AccountID) >= flag.RolloutPercent:
reason = "outside-rollout"
case in.Region != "US-LOWER-48":
reason = "unsupported-region"
case in.SubtotalCents < 7500:
reason = "below-minimum"
}
result := Quote{
StandardCents: 799,
Decision: Decision{
Offered: reason == "eligible", FlagRevision: flag.Revision,
Reason: reason,
},
}
if result.Decision.Offered {
expedited := int64(1499)
result.ExpeditedCents = &expedited
}
return result, nil
}

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.

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 new offers. 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.

3. Saved filters: the interface switch and the user’s own toggle

The third example is a saved-filters panel for an order list. There are two switches here that people tend to conflate. The feature flag says whether this account has access to saved filters. The user preference says whether the available panel is currently shown. If the feature flag is off, the preference cannot manufacture access.

The server returns a capability document after authentication:

{
"savedFilters": true,
"revision": "ui-2026-09-24-3"
}

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.

TypeScript and React

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.

// SavedFiltersFeature.tsx
import { useEffect, useState } from "react";
type Capabilities = { savedFilters: boolean; revision: string };
type Filter = { id: string; name: string; query: string };
export function SavedFiltersFeature() {
const [capability, setCapability] = useState<Capabilities | null>(null);
const [filters, setFilters] = useState<Filter[]>([]);
const [open, setOpen] = useState(
() => localStorage.getItem("saved-filters-open") === "true",
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch("/v1/me/features", { signal: controller.signal, credentials: "include" })
.then(response => {
if (!response.ok) throw new Error("Could not load features");
return response.json() as Promise<Capabilities>;
})
.then(setCapability)
.catch(err => {
if (err.name !== "AbortError") setError(err.message);
});
return () => controller.abort();
}, []);
useEffect(() => {
if (!capability?.savedFilters || !open) return;
const controller = new AbortController();
fetch("/v1/saved-filters", { signal: controller.signal, credentials: "include" })
.then(response => {
if (!response.ok) throw new Error("Could not load saved filters");
return response.json() as Promise<Filter[]>;
})
.then(setFilters)
.catch(err => {
if (err.name !== "AbortError") setError(err.message);
});
return () => controller.abort();
}, [capability?.savedFilters, open]);
if (!capability?.savedFilters) {
return error ? <p role="alert">{error}</p> : null;
}
return <section aria-label="Saved filters">
<label>
<input type="checkbox" checked={open} onChange={event => {
const next = event.target.checked;
setOpen(next);
localStorage.setItem("saved-filters-open", String(next));
}} />
Show saved filters
</label>
{error && <p role="alert">{error}</p>}
{open && <ul>{filters.map(filter =>
<li key={filter.id}><button type="button" onClick={() => {
// The order list owns applying filter.query, after validating its DSL.
window.dispatchEvent(new CustomEvent("apply-saved-filter", {
detail: { id: filter.id },
}));
}}>{filter.name}</button></li>,
)}</ul>}
</section>;
}

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.

The same feature in SwiftUI

On iOS, @AppStorage 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.

import SwiftUI
struct Capabilities: Decodable {
let savedFilters: Bool
let revision: String
}
struct SavedFilter: Decodable, Identifiable {
let id: String
let name: String
let query: String
}
@MainActor
final class SavedFiltersModel: ObservableObject {
@Published private(set) var capability: Capabilities?
@Published private(set) var filters: [SavedFilter] = []
@Published private(set) var errorMessage: String?
// apiBase and URLSession are injected so previews/tests can use fixtures.
private let apiBase: URL
private let session: URLSession
init(apiBase: URL, session: URLSession = .shared) {
self.apiBase = apiBase
self.session = session
}
func loadCapability() async {
do {
let (data, response) = try await session.data(
from: apiBase.appending(path: "v1/me/features"))
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
capability = try JSONDecoder().decode(Capabilities.self, from: data)
if capability?.savedFilters != true { filters = [] }
errorMessage = nil
} catch {
capability = nil // unknown is off
filters = []
errorMessage = "Features could not be loaded."
}
}
func loadFilters() async {
guard capability?.savedFilters == true else { return }
do {
let (data, response) = try await session.data(
from: apiBase.appending(path: "v1/saved-filters"))
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
filters = try JSONDecoder().decode([SavedFilter].self, from: data)
errorMessage = nil
} catch {
filters = []
errorMessage = "Saved filters could not be loaded."
}
}
}
struct SavedFiltersView: View {
@StateObject private var model: SavedFiltersModel
@AppStorage("savedFiltersOpen") private var isOpen = false
let applyFilter: (String) -> Void
init(apiBase: URL, applyFilter: @escaping (String) -> Void) {
_model = StateObject(wrappedValue: SavedFiltersModel(apiBase: apiBase))
self.applyFilter = applyFilter
}
var body: some View {
Group {
if model.capability?.savedFilters == true {
Section("Saved filters") {
Toggle("Show saved filters", isOn: $isOpen)
if isOpen {
ForEach(model.filters) { filter in
Button(filter.name) { applyFilter(filter.id) }
}
}
}
}
if let error = model.errorMessage {
Text(error).foregroundStyle(.red)
}
}
.task { await model.loadCapability() }
.task(id: isOpen && model.capability?.savedFilters == true) {
if isOpen { await model.loadFilters() }
}
}
}

The app’s authenticated URLSession 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.

The switch has a lifecycle

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.

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.

References

Agent-Ready Local Environments and MCP

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, 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.

This post is about making that setup cheap and repeatable.

The real prerequisite is a bootable story

Every repo should answer, in files the agent can read, a small set of questions:

  1. What do I need installed on this machine?
  2. How do I get from clone to a running local system?
  3. Which services does this project talk to, and which of those am I expected to use locally?
  4. Where do secrets live, and what is not allowed?
  5. How do I know the setup worked?

If those answers only exist in your head, the agent will invent something almost right. Almost right is expensive.

I keep this in the product repo as first-class docs and scripts: README, CONTRIBUTING, docs/setup, scripts/bootstrap, .env.example. 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.

Make setup mechanical

Agents are excellent at following a checklist. They are mediocre at inferring your undocumented brew taps and tribal knowledge.

What works well right now:

A single bootstrap path. 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.

Pinned, discoverable tooling. Version files (.nvmrc, .node-version, mise.toml, asdf configs, rust-toolchain, etc.) beat README poetry. The agent should be able to detect the toolchain without asking you which Node major you meant last quarter.

.env.example that matches reality. Every required variable named. No secret values. Short comments for where to get each one. If a variable is only for production, say so.

Smoke tests for the environment. A command that proves the local stack is alive: npm run doctor, make verify, a compose healthcheck, a migration status call. Green means “safe to start feature work.” Red means “fix the machine, not the feature.”

Dev containers or explicit host setup. Pick one and document it. 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.

This is the same instinct as my old dev-setup-osx habit, updated for a world where the “new developer” might be an agent that showed up thirty seconds ago.

Tell the agent where the working systems are

Local build is only half the map. Most real work touches other systems: source hosts, chat, mail, issue trackers, cloud consoles, internal APIs.

Write down the topology.

  • Product repos and their remotes (GitHub, GitLab, both, mirrors).
  • Which environments exist: local, staging, production, preview apps.
  • Which human collaboration surfaces matter: Slack channels, Teams teams, Outlook lists, Linear/Jira projects.
  • Which of those the agent is allowed to read or write.
  • How authentication is supposed to happen for each.

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.

MCP is how agents reach the rest of the desk

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.

The useful pattern is not “connect everything.” It is “connect the systems this project actually depends on, with the least privilege that still helps.”

A sane MCP layout for project work often includes:

  • Source control: GitHub and/or GitLab for issues, PRs, checks, and releases.
  • Chat: Slack or Teams for the channels where decisions and unblockers live.
  • Mail/calendar: Outlook or similar when the work is genuinely gated on threads or meetings, not because it is fun to give an agent your inbox.
  • Project tracking: whatever holds the tickets, if that is not already the git host.
  • Docs and runbooks: if they live outside the repo and agents keep asking for them.

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.

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.”

Boundaries beat cleverness

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.

Give it clear limits:

  • Read-only by default where write access is not required.
  • Explicit allow-lists of repos, channels, and projects.
  • Repo instructions that say when to use MCP versus when to stay local.
  • The same ask-plan-confirm gate you use for code changes when the action leaves the laptop (posting, labeling, merging, emailing).

Local environment setup gets the agent building. MCP gets the agent collaborating. Boundaries keep both from becoming a mess you have to unwind.

A minimal checklist I actually use

When I stand up a project for agent-assisted work, I want at least this:

  1. Clone, bootstrap, and smoke test documented and scripted.
  2. Toolchain versions pinned in-repo.
  3. .env.example complete; real secrets elsewhere.
  4. Notes/brief that name the remotes, environments, and collaboration surfaces.
  5. MCP servers configured for the systems in play, with purpose notes in the project docs.
  6. Clear write/read expectations for each integration.
  7. A first prompt that points the agent at setup docs before feature work.

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 PATH.

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.

Twenty-ish Terminal Commands for Getting Around the File System

Recently I was digging around in the terminal and wanted an ls 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.

This is the resulting quick reference. I’m writing this on macOS with zsh, but most of it applies directly to Linux too. A few tools—particularly tree and fd—may need to be installed first.

First, the Many Faces of ls

The plain command lists the current directory.

ls

Add -l for the vertical, long-listing format. This shows permissions, link count, owner, group, size, modified date, and name.

ls -l

Add -a to include hidden entries—names beginning with a dot—and -h to make sizes human-readable. The switches can be combined.

ls -lah

To descend through every directory and list its contents, add -R for recursive.

ls -laRh

That gets us the fully opened contents, although the output is grouped by directory rather than drawn as a visual tree.

Navigation

1. pwd — Where Am I?

When I’ve wandered six directories deep and forgotten where I am, pwd prints the full path to the working directory.

pwd

2. cd — Change Directory

Move into a directory, up one level, back to the previous directory, or straight home.

cd projects
cd ..
cd -
cd ~

Paths containing spaces need quotes, as in cd "My Projects".

3. pushd and popd — Directory Bookmarks, Sort Of

pushd changes directories while saving the current location on a stack. popd takes me back. This is excellent when bouncing between two distant parts of a repository.

pushd ~/Code/my-project/docs
popd

4. tree — The Actual Tree View

Unlike recursive ls, tree draws the hierarchy. -a includes hidden entries, -L 2 limits output to two levels, and -h prints readable sizes.

tree -a -h -L 2

On macOS, install it with brew install tree if it is not already available.

Finding and Inspecting Things

5. find — Search the Directory Tree

Find every Markdown file below the current directory. Here . means “start here,” -type f limits results to files, and -name supplies the filename pattern.

find . -type f -name "*.md"

Use -type d to find directories instead.

6. fd — A Friendlier Find

fd provides a concise, fast alternative when installed. This finds Markdown files while including hidden paths but excluding .git.

fd --hidden --exclude .git '\.md$'

Install it on macOS with brew install fd.

7. stat — All the File Details

stat reports metadata such as size, permissions, timestamps, and inode information.

stat quick-article.md

The exact output differs between macOS and Linux, but the intent is the same.

8. file — What Is This Thing?

Extensions can lie. file inspects the contents and reports what it believes the file actually is.

file mysterious-download

9. du — What Is Using the Space?

du measures disk usage. -s summarizes instead of listing everything below the target, and -h makes the result readable.

du -sh .
du -sh ./*

10. df — How Much Disk Is Left?

Where du examines files and directories, df reports free and used space on mounted file systems. Again, -h keeps the numbers readable.

df -h

11. less — Read Without Flooding the Terminal

For a file longer than one screen, use less. Search with /text, advance with the space bar, move backward with b, and quit with q.

less application.log

12. head and tail — Inspect the Edges

These show the beginning or end of a file. -n 20 asks for twenty lines. tail -f keeps watching as new lines arrive, which is particularly handy for logs.

head -n 20 application.log
tail -n 20 application.log
tail -f application.log

Creating and Managing Files

13. mkdir — Create Directories

The useful switch here is -p: it creates missing parent directories and does not complain if the path already exists.

mkdir -p notes/terminal/examples

14. touch — Create an Empty File

If the file does not exist, touch creates it. If it does exist, touch updates its timestamps without changing the contents.

touch notes.md

15. cp — Copy

Copy a file with plain cp. Use -R to copy a directory recursively and -i to ask before overwriting something.

cp -i notes.md notes-backup.md
cp -Ri source-folder destination-folder

16. mv — Move or Rename

mv handles both jobs. I often include -i for an overwrite prompt.

mv -i draft.md published.md
mv -i published.md archive/

17. ln — Create a Link

ln -s creates a symbolic link. The first path is the existing target; the second is the new link.

ln -s ~/Code/my-project/current-config.json config.json

18. rmdir — Remove an Empty Directory

rmdir only removes empty directories. That limitation makes it useful when I want the command to refuse anything containing files.

rmdir old-empty-folder

19. rm — Remove Files Carefully

Plain rm removes files. -i asks before each removal. Recursive -R removes directories and their contents, so double-check the path before pressing Return.

rm -i unwanted.txt
rm -Ri unwanted-folder

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.

20. open — Hand It to macOS

open opens a file in its default application. Give it a directory and Finder opens there; use -a to choose an application.

open README.md
open .
open -a "Visual Studio Code" .

On Linux, the closest general equivalent is usually xdg-open.

A Few Combinations I Actually Use

Here are the quick combinations I tend to reach for most often.

# Everything here, including hidden entries, with readable details.
ls -lah

# Everything below here, recursively, in long format.
ls -laRh

# A manageable visual overview, two levels deep.
tree -a -L 2

# Find the biggest immediate entries in the current directory.
du -sh ./* | sort -h

# Jump somewhere temporarily, inspect it, and jump back.
pushd ~/Code/some-project
ls -lah
popd

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 man ls, man find, or man followed by any of the built-in commands above to spelunk through everything else they can do.

Dashing Arrivals: Watch Every Bus, Train, and Ferry in Puget Sound Move in Real Time

There’s a particular kind of magic in watching a whole city move at once. Not one bus, not one train — all of them, gliding across the map in real time, each one a little rectangle of somebody’s commute home.

That’s Dashing Arrivals — 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 over 1,100 vehicles on screen, all updating live.

This post is a tour of what it is and what you can do with it.

The whole region at a glance

An overview of the city in transit.

The default view drops you over Puget Sound with everything turned on. A quick legend does a lot of work here: color tells you the agency and shape tells you the type (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.

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 animate from their last position to the next one, so the whole map has a calm, continuous, living quality instead of a jumpy refresh.

Click any vehicle for the details

Showing Route 1 Line

Tap a vehicle and it tells you who it is: the route, the agency, the fleet number, its heading (as a compass direction and degrees), its speed, and when it last reported in. Here’s a 1 Line Link train in the Rainier Valley, heading south at 159°.

See that Show route ▸ link at the top of the popup? That’s where it gets fun.

See the entire route

Click Show route 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.

Link light rail line 1 in the south city.

Here’s the 1 Line, 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.

Link Light Rail 2 Line

And the newer 2 Line, 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.)

Rail beyond Link: the Sounder

Light rail isn’t the only train out there. Pick a Sounder commuter-rail train and you get the full BNSF corridor.

South Sounder Commuter Rail Line.

This is the S Line, 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.

Buses, of course — every route

The same trick works for any bus. Click one, hit Show route, and the corridor lights up in that agency’s color.

C Line Rapid Ride.

The RapidRide C Line from West Seattle into downtown, in Metro green.

E Line Rapid Ride.

And the RapidRide E Line, 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.

Filter the firehose

Eleven-hundred vehicles is a lot. The Show and Agencies panel lets you dial it in — toggle whole modes or individual agencies on and off.

Filtering with busses off and only showing the trains and ferries.

Here I’ve switched off buses 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.

When’s my ride? Live arrivals at any stop

Zoom in and the stops appear. Click one to get a live arrivals board.

Stop arrivals.

This is the Symphony stop downtown, with real-time predictions counting down — Due, Due, 1 min, 4 min, 6 min… — 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.

Know before you go: service alerts

Service alerts!

The pill at the top of the screen keeps a running count of active service alerts 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.

Light or dark, your call

Prefer a bright map? A theme toggle switches between a sleek dark basemap and a clean light one (with a System option that follows your OS). Same live data, different mood.

Under the hood

For the curious, Dashing Arrivals is a modern, open-source web app:

  • Live data comes from the OneBusAway Puget Sound regional API, decoded from GTFS‑realtime vehicle-position feeds. A single regional feed covers King County Metro, Sound Transit, Community Transit, Pierce Transit, Kitsap Transit, and Everett Transit.
  • Ferries come from a separate source — the WSDOT Washington State Ferries vessel-location API — and are normalized in alongside everything else.
  • The map is MapLibre GL on an open OpenFreeMap basemap (no proprietary map key required), with the app built on Next.js and React and deployed on Vercel.
  • Vehicle positions are fetched on a short polling loop and cached server-side, then eased between updates on the client so motion looks smooth. Icons are tinted per agency, sized by zoom, and rotated to the reported heading.

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!

Go watch the city move

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.

Try it for yourself at dashingarrivals.com.


Screenshots captured live from dashingarrivals.com. Basemap © OpenFreeMap / OpenMapTiles, data © OpenStreetMap contributors. Real-time transit data via OneBusAway and WSDOT.

Ask, Plan, Confirm: Making Agents Stop Before They Start

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.

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.

Why speed is the problem

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.

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.

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.

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.

The same task, gated and ungated. Skipping the pause trades a small, predictable cost for a large, unpredictable one.

So I gave the pause back, deliberately, as a standing instruction on every agent in the InterlinedList repo.

The three beats

The workflow is written down in .claude/workflows/plan-first.md and every agent links to it. It is three beats, in order, and implementation is gated behind all three.

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.

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.

Ask. 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.

Plan. 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.

Confirm. 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.

What it looks like per agent

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.

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.

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.

The migrations agent plans the exact idempotent migration.sql and confirms it is purely additive before it applies anything. The unit-testing agent asks which behaviors to lock in and which boundaries to mock, then lists the cases each test file will assert. The e2e agent names the flows, the auth and seed prerequisites, and the breakpoints that matter. The docs agent confirms which of the three docs is in scope and whether a new page is needed. The blog agent (yes, this one) settles the angle and the section arc and which real code it will verify claims against before drafting a word.

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.

The obvious objection

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.

It also composes with the other habit I built into these agents: every one of them does its work in an isolated git worktree, 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.

The pause was always the expensive part of good engineering. Worth teaching the machines to keep it.

I’m Adron, brainstorming and building InterlinedList.