← Ledger


Date: 2026-08-09 Status: Accepted

Context

This repository feeds four Vercel projects from one tree:

project Root Directory ignoreCommand before this change
erid-apex apps/erid-apex none
erid-coming-soon apps/erid-coming-soon none
erid-docs apps/erid-docs none
erid-cairnet cairnet none

Every push therefore rebuilt all four, whatever changed. That was defensible while build minutes were assumed to scale with build duration. They do not.

Vercel bills build CPU as

cores x ceil(wall minutes), with a one-minute floor

so a deployment that compiles nothing still bills a full 4 core-minutes on a 4-core machine, and only a CANCELED deployment is free — a fast READY one is billed in full. Deployment count is the cost driver, not duration. This was measured across a 47-project fleet on 2026-08-09; erid-docs and erid-apex each land on exactly 10 deployments x 4 cores = 40 billed minutes for the cycle, which is what quantisation on the core count looks like.

One commit in this repo's own history is the argument. a83e407, "chore(console): bump to 1ddf1fb", changes exactly one path — the console submodule pointer, which no application in this tree reads. It deployed erid-docs twice (two branches, same SHA): 8 billed core-minutes for a pointer move. rocky-console is its own Vercel project and builds from its own repo; the erid four had no business rebuilding.

Decision

Add one shared Ignored Build Step, scripts/vercel-ignore.sh, and wire it into all four projects via an ignoreCommand in each Root Directory's vercel.json.

The script exits 0 to skip and 1 to build, and every uncertain branch builds. It is a direct port of the same guard already proven in production in bhava-blue/bhava-blue and petrova-codes/petrova, deliberately copied rather than re-derived — see Two traps below for what re-deriving costs.

Build inputs are not Root Directories

Each app here is self-contained: its own package.json and package-lock.json, no root workspace, and no src/ importing across its own boundary. So for three of the four the predicate really is just the project's own directory.

erid-docs is the exception, and it is why this list is written by hand rather than generated from the Root Directory settings. apps/erid-docs/src/lib/ledger.ts does:

const ROOT = join(process.cwd(), '..', '..');
const DECISIONS_DIR = join(ROOT, 'docs', 'decisions');
const SPECS_DIR     = join(ROOT, 'docs', 'specs');

and src/app/[...slug]/page.tsx calls generateStaticParams() over the result, so the repo-root ledger is baked in at build time. docs/decisions/ and docs/specs/ are erid-docs build inputs. Measured over 203 non-merge commits, a naive apps-only predicate would have skipped 56 of them wrongly — 28% of all history quietly serving a stale ledger. Note also that listMarkdown swallows a read failure with catch { return [] }, so a wrong skip would have rendered as an empty ledger rather than as a build error. This document exists in the directory that this app publishes; the guard has to know that.

console, ralph, regolith, atlas, hearth, lore and design-tokens are deliberately absent from every path list — nothing here reads them at build time. lore/ carries its own vercel.json but has no Vercel project in the account, so it is nothing's input.

Two traps, and they fail in opposite directions

Fail open — merge commits. git show --name-only on a merge commit is empty. A predicate written against the head commit therefore sees no changed files and skips the merge that shipped the change. Nearly half of this repo's main-branch commits are merges, so a from-scratch guard would have stopped deploying real work. The script diffs base..head instead, which sees through merges: on cce982d ("Merge pull request #123"), git show yields nothing while git diff parent1..merge correctly yields cairnet/src/middleware.ts.

Fail closed — the shallow clone. VERCEL_GIT_PREVIOUS_SHA is the last deployed commit, not the previous commit in the log, so the better the guard works the further back that SHA sits — and Vercel's clone is shallow. Without deepening, a correctly fail-closed guard builds every time and skips nothing. This was observed in petrova-codes production as base 6d920c4 not in clone and took three attempts to close there. The script unshallows (falling back to --deepen=250) before giving up.

Verification

13 cases replayed against real commits in this repository, before merge:

Sized over 203 non-merge commits, using the corrected input sets:

project commits touching its inputs would skip
erid-apex 11 / 203 192 (94%)
erid-coming-soon 10 / 203 193 (95%)
erid-cairnet 15 / 203 188 (92%)
erid-docs 66 / 203 137 (67%)

No realised reduction is claimed. That table is the predicate's decision over commits that already happened; future commit mix is not obliged to resemble it, and per-commit replay is not per-deployment billing — Vercel deploys per push, and several of these commits arrive in one push. The number that matters comes off the Vercel dashboard a cycle from now, and a production SKIP is not proven until CANCELED deployments are observed. An ignore step that is present and runs is not the same as one that skips.

Consequences