Process
How work gets done here. Short on purpose. Every rule below exists because breaking it cost rework, documented in docs/LESSONS.md. See docs/METHOD.md for where every other methodological document lives.
Rules that can be enforced by a command are enforced by a command. The rest are here because someone has to remember them.
Every rule names its enforcer. A rule enforced by a gate says so, and a reader can stop worrying about it. A rule enforced only by attention says that too, and is therefore a candidate for automation.
An earlier version added "a rule with no enforcer named has not been thought through", which was too strict to apply and was therefore not applied. Five of the rules below are unenforceable by construction and are the most valuable five here: never change the truth to satisfy a check, fan out on files not on features, names do not overpromise, an external setup is documented as steps, and a control must fail loudly. A check cannot judge any of them, because each is about the judgement that precedes writing a check.
Retirement, exercised 2026-09-12 for the first time. Four rules were compressed to one line naming the gate that now enforces them - skipped is not passed, docs make testable claims, every external service gets an adapter, and the prose budget - and the scratchpad-partition rule was folded into the worktree rule it is a special case of. The reasoning that earned each one is in docs/LESSONS.md, which is where the reader who wants it will look.
Being honest about the arithmetic, because this document is the one that says to be: the retirement removed about 130 lines and the same pass added about 210 carrying corrections the 2026-09-12 review round earned - a borrowed justification in "fix in execution order", the second direction of "choose, do not alias", a hole in the unearned-abstraction test, and a false enforcer on the first rule. So the count fell by one and the volume rose. The general review's larger ask - compress to roughly twelve rules and 250 lines - is a deliberate unit rather than a tail-end edit, because nineteen of these rules carry reasoning that was paid for once and cannot be re-derived. DOC-016 carries it.
Done
Done is a command that exits zero.
Not a description, not a file list, not a summary. When work is reported complete, the report contains the command and its output. Anyone can re-run it. If there is no such command, the work is not done, it is described.
Enforced by: make verify (see docs/VERIFICATION.md). verify runs every gate, prints a summary naming each one, and exits non-zero if any failed. It ran one gate in eleven until OPS-019; docs/LESSONS.md lesson 14 is why that was worse than it sounds. make report is the same run with the exit status discarded, for a baseline record.
Exiting zero is still not the same as every gate being green. verify is expected to have red gates and red is information. What the rule requires is that the command names all of them.
Gate before fix
A gate is written before the code it guards, and it must fail first.
A check written after the fix gets written to pass. A check never observed failing is unverified. When adding a gate to catch a known bug, land the failing gate first, then fix until green.
Never change the truth to satisfy a check
When a gate and reality disagree, fix the gate.
A check exists to describe reality. The moment it is easier to edit reality to match the check, the check has stopped measuring anything and starts actively lying, because it will now be green for the wrong reason.
The case that earned this rule: component-deps flags email-validator as an unused requirement of auth. It is a hard runtime dependency, used through EmailStr, and correctly declared. The gate's rule is declared - imported, which cannot express a dependency that is imported by a dependency, and no spelling of the manifest satisfies it. Dropping the declaration would have turned the gate green and made the manifest false, and the product installs the package either way. The agent left it red and reported it. That was correct.
Applies equally to type checkers and linters. A # type: ignore that hides a real error, a ruff suppression on a genuine finding, or a test assertion loosened until it passes are all the same move. If the tool is wrong, say so in the report, leave the finding visible, and fix the tool. If a suppression is genuinely right, it carries a comment saying why and a ticket id.
Corollary: a gate turning red because someone did honest work is information, not a regression. Count it as the gate earning its keep.
And do not change the design to satisfy a check either. The same rule one level up, and it is easier to miss because the result compiles and the gate goes green.
The case that earned it: the billing contract found itself forced to specify the literal route /webhooks/stripe, because check_vision_capabilities.py matches route probes by startswith against the AST, and a provider-neutral /webhooks/{provider} would leave the capability ledger reporting billing absent forever. A vendor name was about to be baked into a URL path, in a template whose whole premise is that providers are swappable, to keep a lens green.
The lens is the half that is wrong. A capability probe should resolve the adapter registry or match a provider-neutral prefix, not require a vendor's name in a route.
Test for this one: if a check's shape is dictating an interface, ask what interface you would design if the check did not exist. If the answer differs, fix the check.
Reproduce before fixing
Observe the failure yourself before you change a line.
Distinct from gate before fix, which is about the automated check. This one is about you. A fix for a bug nobody reproduced is a guess wearing the costume of a fix, and it is indistinguishable from a real fix until it reaches production.
Two from this repo. SoftDeleteMixin was switched to UTC without anything demonstrating a wrong timestamp, so whether it mattered is still unknown. CORE-001 shipped a rate limiting fix for a problem nobody had reproduced, was recorded as a fixed P0, and did not work - a later probe sent four requests with the limiter disabled and got [200, 200, 429, 429].
The reproduction becomes the test. It is not scaffolding to throw away. It graduates into the contract's acceptance criteria, per docs/PIPELINE.md, so the same defect cannot ship twice. The rate limit probe above is a better criterion than any prose could be, precisely because it already caught something real.
Pin the reproduction. An unpinned one is not a reproduction. Two reviews of this repo reported mypy at 20 errors and at 28, and ruff at 23 and at 37, both honestly, because neither had pinned the invocation. Record the exact command, the working directory, and the environment.
Record which you have. Every finding is confirmed or plausible, per the scoring block in docs/TICKETS.md. Confirmed means a command demonstrated it. Plausible means it was reasoned about. Reading is still valuable and finds real things, it just may not claim confirmation.
No tool without an invocation
A quality tool is adopted when CI runs it and the build fails on its output.
Config in a pyproject is not adoption. mypy sat at strict = true in this repo for its whole life without ever starting. Add the config and the CI invocation in the same change.
Run a gate the way an agent will run it
Call the tool, not the function. From where the work happens, not from the repository root.
The most expensive defect of this session was invisible to every other method. Four gates filtered their walk with the skip list intersected against the parts of an absolute path, and .claude is in every skip list. Agent worktrees live under worktrees/, and the rule above requires any agent writing code to work in one. So every gate run by an obedient agent inspected an empty tree and reported confidently about it: 15 bogus unused requirements, 25 bogus broken exports, and two gates passing on nothing at all.
The rule requiring worktrees silently blinded every gate for every agent that followed it, and no amount of reading the gate code would have shown it. The agent that found it did so only because its brief demanded running make gates rather than calling the functions.
Generalised: a gate has an environment, and the environment is part of the gate. Test it from the directory an agent will actually be standing in.
Skipped is not passed
A check that cannot run reports "skipped", says why, and is never counted as a pass.
Retired to one line 2026-09-12. Enforced in code rather than by attention: cli/gates/result.py's Status is a three-valued StrEnum with no way to express a two-valued result, GateResult.skip() requires a reason, and saas check and saas doctor both print "a skipped check is not a passing check" with a non-zero exit. The incident that earned it - saas check reporting "All 11 checks passed" against a project that could not import - is docs/LESSONS.md lesson 6.
Fix in execution order
Install, import, start, serve, then behaviour.
Findings arrive in order of visibility, which has nothing to do with depth. An upstream break hides everything below it, so re-run the full gate after each fix because upstream repairs change what downstream gates can see.
This rule was once borrowed to justify something it does not say. docs/VERIFICATION.md cited it as the reason make verify short-circuited. The execution order here is install, import, start, serve, behaviour - what has to work before the next failure can even be observed. lint is not upstream of anything. A ruff finding in a documentation generator does not hide a test failure, it stands in front of one. docs/LESSONS.md lesson 14 is the other half, and OPS-019 fixed it: verify runs every gate and summarises. The rule above still applies to the fixing, which is done in execution order. It never applied to the running.
Fan out on files, not on features
Before launching parallel agents, list the files each will create or modify. Any file appearing twice is a scheduled conflict. Any shared interface without a single owner is the same conflict.
Either write and freeze the interface first and hand it to every agent, or sequence them so the second consumes what the first produced. Two agents were once told to build adapters and each to create the registry if absent. Both did. The repo still carries two vocabularies for one registry.
An agent that writes code works in a worktree, and owns its scratch directory too
Any agent that modifies code gets isolation: worktree. Not optional. And it gets $SCRATCHPAD/<its-own-name>/ and nothing above it.
Partitioning by file is necessary and not sufficient. A careful partition still shares one working tree, so the moment an agent dies mid-edit its partial work is loose in the tree and indistinguishable from everyone else's. Then nothing can be committed safely, because no one can say who wrote which line.
A worktree makes every agent's output a branch. A death becomes a branch you discard. A success becomes a branch you review and merge, which is a place where a human or a reviewing lens can actually stand.
This was learned twice in one session. The CORE and ADAPT agents ran isolated and their failures were recoverable. Six later agents ran in the shared tree, four died to a dropped connection, and the result was about twenty modified files with no attribution.
Agents that only read, and agents that only write markdown they exclusively own, can share the tree. The line is code.
The scratchpad is the same rule one directory over, and it was a separate rule here until 2026-09-12. The worktree isolates the repository and says nothing about the scratch directory, which is per session rather than per agent - a shared writable tree where every agent builds its test product. So an agent told to work in an isolated worktree reasonably assumes its scratch space is isolated too, and reaches for rm -rf on a path it believes it owns. That happened: one agent removed a build directory and deleted another agent's test project with it. Nothing committed was lost and the mistake was in the brief. Partitioning by a convention nobody stated fails, and rm -rf does not ask twice.
Merging from a worktree is still subject to "choose, do not alias" below.
Fast-forward to main as your first action. The harness does not cut a worktree from current main. Three agents in one session were handed a base at f875cfb, respectively 44, 59 and 73 commits behind, missing the gates, the process documents and in one case the whole tests/ tree. Two recovered with a lossless fast-forward because they had no commits of their own yet. The third had already done its work and had to be re-cut and replayed.
So the first command in a worktree is git merge --ff-only main, before reading anything, because half the files a brief names may not exist yet.
Then check again before you finish. Isolation buys attribution and costs freshness. One agent ran for twenty minutes while main took 44 commits, and its base was missing half the files its brief told it to read, including the gates whose numbers the brief quoted. It correctly refused to let its branch be signed.
So: an agent checks git log --oneline HEAD..main before reporting, and says prominently if main has moved in files it touched. It commits incrementally, so a stale finish still leaves usable work. And a long job is better split than run long, because the drift is proportional to duration.
Hand overs go in one place
An agent that cannot make a change because another agent owns the file writes the exact lines to HANDOVERS.md, with the owning file and its own id.
Scoping agents tightly is correct and it produces a steady stream of one-line changes stranded outside their author's reach. Left in individual reports, those lines are found only by whoever happens to read that report, which in practice means the coordinator relaying them by hand and occasionally not.
One file, append only, each entry naming the target file, the exact change, and who found it. Draining it is a normal piece of work that any agent can be given. An entry is deleted only by whoever applies it, in the commit that applies it.
Choose, do not alias
When parallel work diverges on a name, pick one and fix the call sites.
Better still, look for the spelling that needs no reconciliation. The packaging work is the counter-example worth copying: two conventions were in play for importing across components, absolute components.core.x and relative ...core.x, and neither was going to survive the copy into a product intact. The fix was not to pick a winner and rewrite the loser on copy. It was to notice that relative imports resolve correctly in both worlds already, because the shared parent package is itself the alias, spelled ... One spelling, nothing rewritten, no shim.
So the order is: find the spelling that works everywhere, and only if none exists, choose one and fix the call sites.
Making both spellings work is faster under a merge conflict and permanent afterwards. A compatibility shim needs a real prior release to be compatible with, plus a deprecation date and a backlog ID in the comment. Otherwise it does not go in.
And the rule reads backwards as well, which nothing was watching for. Two names for one thing is the half everyone remembers. Two things sharing one name is the same failure read from the other end, and it is harder to see because no grep for aliases finds it. components/core defines get_settings twice - one cached from the process environment, one reading app.state - and lists both in its own provides manifest three lines apart. They disagree, and in a scaffolded project get_adapter("email") returned the Resend adapter for an app built with console and raised. Nothing warned, because a direct-alias grep returns nothing and export-claims checks divergent aliases rather than convergent names. CORE-030.
The test for this direction: for any name you are about to write, grep for the name first, not for the thing.
Every review pass runs the code
At least one agent per review pass is empirical: build it, run it, paste the transcript.
Four design reviews read this repo and none found that its output could not import. The reviews that found it differed in one respect, they ran it. Design review stays, it is just never the only pass.
Docs make testable claims or get marked
A doc describing current behaviour is testable, so test the testable parts. Forward-looking documents say so in the document or live in backlog/.
Retired to one line 2026-09-12. Enforced by four gates rather than by attention: scripts/check_doc_paths.py resolves every backticked path in a document, scripts/check_docstring_paths.py resolves the same claim inside a Python docstring or comment, scripts/check_references.py resolves every id, and scripts/gen_docs_index.py rejects a document whose status is missing or not one of the four. The reasoning is docs/LESSONS.md lesson 7.
A docstring is a document, which is what DOC-050 cost to learn. The path rule was green over 136 markdown files while four references to a test file that has never existed sat in .py prose, two of them in prose that ships into every scaffolded product. Nothing was wrong with the rule. It was pointed at one of the two languages this repo writes English in.
Two things the gates do not cover, and they are where the next one will land. A code block is not a path, so skeleton/CLAUDE.md.j2 could teach a call that raises TypeError with the path rule green above it (DOC-030). And the two gates disagree about status: planned, so there is no spelling of a planned contract that satisfies both (DOC-020).
Prose budget
Markdown is not progress. Before adding a document, ask whether the thing being written down could instead be a check that fails. If it could, write the check.
Compressed 2026-09-12. The rule was written when this repo held 6,628 lines of prose against 3,659 of Python, and it has not worked: both grew about ninefold in a day and the ratio is roughly where it was. What the rule can actually do is the one sentence above, so that is all that is left of it. Counting lines is docs/LESSONS.md's job and no number is repeated here, because every number this document has ever carried in prose went stale (DOC-016).
Names do not overpromise
A name is a promise about scope. Name a thing for what it is, not for the territory it might one day cover.
Test: can you name something a reader would reasonably expect from the name that the thing does not do? If yes, the name overpromises and needs a narrower one.
Two examples from this repo.
AI.md claimed everything about AI and delivered a guide to this repo's review machinery. It is now docs/AGENT-TOOLING.md. The over-broad name also let a second, different AI.md exist under skeleton/, so one name meant two things.
components/core/ is the expensive one. The name promises nothing specific, so nothing was ever out of scope for it. It now holds models, schemas, routes, config, middleware, logging and adapters, and its Settings class carries every provider credential in the system. Four reviewers independently flagged that class. The directory name is upstream of the god object.
An over-broad name is the same failure as a doc describing code that does not exist. Both promise more than is delivered, and the name repeats the promise every time someone reads the path.
Distinguish unearned abstraction from unfinished work
Before deleting code, decide which of the two it is. They look identical and the right answer is opposite.
Unearned abstraction was built before a second caller existed. Three LLM adapters with no caller. PaginatedResponse and SoftDeleteMixin with no consumer. There was never intent behind it beyond symmetry, so deleting it removes maintenance burden and loses nothing. A second implementation proves a Protocol. A third before anyone has used one is decoration.
Unfinished work is a placeholder for something genuinely wanted. cli/commands/upgrade.py is that: there is a live ticket, and a template whose premise is that products stay current with it needs component upgrades. Deleting the stub removes no burden worth removing and destroys a placeholder, moving the information somewhere less discoverable. saas --help listing upgrade tells a founder the feature is coming. Its absence tells them nothing.
The test: is there a ticket, and would you accept a patch implementing it tomorrow? If yes, it is unfinished work.
The test has a hole and the round found it. ADAPT-002 is open and asks for a fourth LLM provider, so the test's own answer for three uncalled adapters is "unfinished work, keep it" - which is how a rule written to prevent over-deletion came to protect five hundred lines that two shipped products walked past. A ticket asking for more of a thing with no caller is not evidence of intent, it is the same unearned abstraction with a ticket attached. Read the second half of the test as load-bearing: would you accept the patch tomorrow, meaning is there a caller waiting for it. CORE-019, and what is wanted there is a recorded decision either way rather than a third round of silence. Do not delete it, make it honest - exit non-zero, name the ticket, and strip any code that runs before the "not implemented" line, because work performed for no reason is the part that misleads.
This rule exists because a review's delete list conflated the two and a brief repeated the conflation. Ritchie's lens is right that every abstraction is paid for forever by one maintainer, and that argument does not reach a stub whose absence is the thing being paid for.
Every external service gets a role, an adapter, and a named alternative
Three things. A role in docs/SERVICES.md, not a vendor, because the role outlives the vendor. An adapter: a Protocol in components/core/adapters/<role>/ per ADR 002, with a default implementation that needs no credential, and the vendor SDK imported inside its adapter and nowhere else. And at least one named alternative with its migration path - named, not implemented. Knowing how you would leave costs a paragraph and is the difference between choosing a vendor and being captured by one.
Compressed 2026-09-12. Enforced by .importlinter's vendor-sdks-live-in-adapters and identity-sdks-live-in-the-identity-adapter contracts, both landed green so the first violation is the one that fails.
This is not licence to build the alternative, and that half is not enforced by anything. Two implementations prove a Protocol; this repo shipped three LLM adapters with zero callers and they are still here two review rounds later (CORE-019). The second implementation waits for a second caller. So when the question is "should we have a document store ready", the answer is: define the role, name Postgres JSONB as the first fill and Mongo as the alternative, and build nothing.
An external setup is documented as steps, not as a link
If a human had to do something outside this repo to make it work, the sequence lands in docs/services/<service>.md in this repo, as commands, in order.
Not a link to the vendor's page. A vendor page is written for everyone, changes without notice, and never says which of its twelve options this project needs. The point of the guide is that the project is self contained even where it depends on something external.
Every service guide states five things, per docs/PATHS.md P7:
- What the service is for, by role.
docs/SERVICES.mdowns the role. - What you need before starting, including whether it costs money.
- How to create the account.
- Which credential to obtain and where it goes.
- How to verify it works, as a command whose output you can check.
The fifth is the one that gets skipped and the one that matters. Obtaining a credential and believing it works are different states.
Write it while doing it. docs/services/flyio.md exists because the setup was walked in a live session and nobody had recorded a single command of it - the path for service guides existed and nothing had triggered it, which is how work goes unscheduled. A guide written from memory a week later is a guess.
Mark where the road ends. A guide covering three of eight steps says so, with the rest under status: mixed and inline (planned) markers. A guide that stops without saying it has stopped is worse than one that admits it.
And document the teardown. Know how to undo a setup before doing it. A forgotten cloud resource bills indefinitely.
A control must fail loudly, whichever way it fails
The terms
Fail closed means: when the control cannot do its job, deny. Fail open means: when it cannot do its job, allow. A door that locks when the power dies fails closed. A door that unlocks fails open.
Neither is always right. A fire exit fails open deliberately. A vault fails closed. This project has chosen both ways on purpose: billing entitlement fails open with a staleness window, because a payment provider outage must not lock out paying customers, and RATE_LIMIT_REDIS_URL fails closed with a named error rather than booting an app whose rate limiting silently does nothing.
Fail loud is the part that is not a choice. Whichever direction a control fails in, the failure has to be reported somewhere that stops the work.
The rule
Silence is the defect. The direction is a choice.
An earlier version of this rule said only that a control failing open must fail loudly, which was the wrong half. A redactor in saas doctor failed closed and silently - it over-matched, refused, and raised bare, so the command exited 1 with zero bytes of output. Its author's observation is the one that corrected this section: that is the awk redactor below approached from the opposite direction, and quiet is the identical part.
Fail open and a secret leaks. Fail closed and the work stops for no stated reason, which gets the control removed by whoever is trying to ship. Both are survivable. Neither is survivable quietly.
The example that earned it
A deploy script redacted credentials before logging them, through awk:
# what it did
echo "$url" | awk '{ gsub(/:\/\/[^ \t@/]*@/, "://***@"); print }'On macOS that regex literal is a syntax error: one-true-awk chokes on the unescaped / inside the bracket expression. What happened next is the whole lesson:
awkprinted a complaint to stderr, which nobody was reading.printstill ran, so output still appeared and looked normal.- The exit code was 0, so
set -edid not fire and no caller noticed. - The password went into the log unmasked.
And it had passed in CI, because the container's awk is GNU awk, which accepts that regex. So the control was absent on exactly the machine a human runs it on, and present on the machine nobody reads the logs of.
The fix is not a better regex. It is that a redactor asserts on its own output:
redacted=$(printf '%s' "$url" | python3 -c '...')
case "$redacted" in
*"***"*) ;; # it actually redacted
*) echo "redactor produced no mask, refusing" >&2; exit 1 ;;
esacNow a broken redactor stops the deploy instead of publishing a password.
Where else this applies
Any control whose failure mode is silence. A signature verifier that returns true on a parse error. An authorization check wrapped in a bare except that returns the unauthenticated path. A sanitiser that returns its input unchanged when it cannot parse it. A secret scanner that finds nothing because its config file is missing.
How to test for it
Break the control's dependency and confirm the work stops. Delete its config, rename its binary, hand it input it cannot parse. If the pipeline continues, the control is decoration in the one situation it exists for.
Enforced by: attention, and it should not be. A gate could run each control against a broken dependency and require a non-zero exit.
Retire rules that gates now enforce
This document only grows unless someone prunes it, and a document nobody finishes reading enforces nothing.
When a gate begins catching a rule's failure mode, the rule stops being an instruction and becomes an explanation of a gate. Compress it to one line naming the gate, and move the reasoning to docs/LESSONS.md where the reader who wants it will look.
Adding a rule is a prompt to retire one. No number here, deliberately: this paragraph said "sixteen" from the commit that took the count from 16 to 19 in the same diff, and stayed at "sixteen" through nine more commits to 25 while nothing was ever retired. Every number this document has carried in prose went stale, and docs/LESSONS.md is where the counting argument belongs.
The general review's rule, which applies well beyond this document: delete every number in prose that a gate already asserts. All of them are currently wrong and each was right when typed. The counter-example worth copying is README.md's scaffold file count, which is asserted by tests/skeleton/test_scaffold_contents.py rather than written down, because it was wrong twice in one day as a number.
Enforced by: attention. A gate could count the rules and the unenforced ones, and that gate is a dozen lines.
Exercised 2026-09-12, for the first time, by the review-round synthesis: four rules compressed to one line naming their gate, one folded into the rule it is a special case of, and the five that are unenforceable by construction named in the preamble so nobody tries. DOC-016 carries what is left.
Notice when you stop converging
Work is converging when a round of fixes closes more than it opens. Check it, rather than assuming it.
Three signals, any one of which means stop and re-plan rather than launching more agents:
- A round produces more new findings than it closes.
- A failure mode in
docs/LESSONS.mdgains instances faster than rules and gates retire them. - The same file is edited by three consecutive rounds without its gate going green.
The point is not that new findings are bad. Finding things is the job. The point is that a system generating findings faster than it resolves them is not improving, it is documenting itself, and the honest move is to narrow scope until the rate reverses.
Checked 2026-09-12, and the answer is mixed in a way worth keeping. Signal 1 fired: five convergence checks closed 13 of 22, 16 of 19, 17 of 31, 9 of 20 and 5 of 5 prior findings, with zero fixed in appearance only - and the same round then opened about sixty new tickets, which is more than it closed. Signal 2 fired: lesson 9 gained four instances in one pass. Signal 3 did not: components/core/config/database.py has not been edited by three consecutive rounds, it has not been edited at all.
That third result is the useful one. The residue is not work that resists fixing, it is work nothing was sent to. So the response was not to narrow scope, it was to dispatch by file. docs/QUEUE.md records the reasoning.
Enforced by: attention, and it should not be. A script over the session log and the gate history could compute all three.
Size a unit so it finishes
A unit of work is small enough that its worktree does not go stale before it lands.
Units in this project have ranged from a two-line build table to "make install and import work", which took twenty minutes and forty-eight tool calls. The second one went stale mid-flight while main took 44 commits, and a second agent had to replay it.
So the size of a unit is not an aesthetic question, it is set by how fast main moves. If a unit cannot land inside that window, split it. A unit that must run long commits incrementally, checks git log --oneline HEAD..main before reporting, and says so if it drifted.
Enforced by: the staleness check in docs/PIPELINE.md, after the fact.
Commits
Reference the source: Ref: CORE-001 or Ref: backlog/core/rate-limiting.md. See docs/TICKETS.md and docs/CONTRIBUTING.md.
No Claude attribution, no Co-Authored-By trailers, no generated-with lines.