docs(arch): scaffold engineering docs and seed three rules - #70
Merged
Conversation
Adds a new top-level docs directory dedicated to durable architecture
guidance, separate from the existing docs/internal/ (process) and
docs/learnings/ (postmortems) splits:
docs/engineering/architecture/
decisions/ Architecture Decision Records (one file per decision)
rules/ Standing rules (always-on, CI or review enforced)
The two README files in each subfolder define the naming convention
(NNNN-short-slug.md), the status lifecycle, and the authoring format
for their respective artefact type.
The first three rules capture the project's stance on the day-to-day
contributor gesture:
0001 — Project Mindset: Excellence by Default. Ten absolute
invariants (no shortcuts, no conscious debt, no speculative
abstractions, no any, no silent failures, no compiler
bypass, no unjustified dependency, optimise for the reader,
excellence is silent). Enforced through code review and
onboarding; no exceptions.
0002 — File Separation: by Concern, not by Syntax Kind. Types,
constants, and functions split into their own files within
a concern, but no global types.ts or utils.ts that re-exports
across concerns. Captures the failure mode of premature
centralisation in either direction.
0003 — File Placement: Decide Before You Create. Every new file
justifies its location before it exists. A single-caller
helper stays next to its caller; extraction to a shared
location requires a second real use site. Captures the
failure mode of premature extraction.
All three rules are written to be readable in five years, by a
contributor who never saw the current state of the codebase.
Two rules that capture the next layer of contributor discipline,
layered on top of the mindset (0001) and the structure (0002-0003):
0004 — No Speculative Defences. A runtime guard exists to handle
a scenario that has been demonstrated, not one the author
imagined. The rule formalises the four-question checklist
(input contract → type-system expressible → runtime-only case
→ has it actually happened) and names the smell: a guard
whose comment reads 'just in case' or is empty.
0005 — Named Algorithms and Independent Data Structures.
Three invariants in one rule: algorithms live as named
functions, not as inline code with a comment-naming-it;
no diminutives (DFS, ctx, arr, mgr) — spell them out; data
structures used by an algorithm are independent things
(a Stack<T> is a Stack<T>, not an InheritanceWalkerState
that happens to be a stack today).
Both rules were directly inspired by reading
packages/errors/src/is/index.ts: the inline DFS with cycle-detection
set, the swallowed try/catch for a non-existent cross-realm scenario,
and the duplicate narrowing. The rules are written without naming the
file; they apply to any code that takes the same shape.
Two rules that capture the project-level philosophy:
0006 — Technology Choices: Assumptions Made Explicit. Every
language mode, module system, validator strategy, and
dependency philosophy is a deliberate assumption. Each
must answer four questions in a paragraph: what is the
choice, what does it enable, what does it rule out, when
would we revisit. The rule captures the shape of an
honest choice and the smell of an inherited default.
0007 — Top-Down Composition: the Consumer's Eye Wins. A function
reads top-down: the first line tells the reader what the
function does; every subsequent step is a name the consumer
follows. The rule formalises the DX constraint that keeps
top-down honest (refactors that serve the reader win over
cleverness that serves the author) and names the smells
(twenty-line algorithm in a one-line body, helpers named
after implementation not intention).
Together with 0001-0005, these form the seven-rule set that the
project applies to every contribution. The rules are independent in
grain: each captures one principle, none duplicates another. They
age by intent, not by example.
Three rules that close the loop on the type layer and the runtime
boundary:
0008 — No Chained Type Assertions. `as X as Y` and `as
unknown as Y` are forbidden. A single assertion crossing
one boundary is allowed; a chain is a confession that the
author has lost the thread of the type. The rule names
three fixes (runtime guard, change source type, typed
accessor) and forbids the chained shape mechanically.
0009 — Open Extension, Closed Modification. A function that
branches on an internally-defined enumeration dispatches
through a Map or typed table, not a chain of if/else. New
values are added by extending the registry, not by
editing the dispatcher. Names the smell explicitly and
distinguishes the registry case from the validation case.
0010 — Typed Environment Access. `process.env` is read in
exactly one file per workspace; the rest of the codebase
imports a typed accessor. The rule captures the
consequences (no scattered casts, no undocumented keys, no
runtime-global typing escape hatches) and provides the
refactor recipe.
These three were directly inspired by reading the existing source:
the chained `as unknown as Record<...>` in the factory, the
modifier chain in the template formatter, and the ambient
`declare const process` scattered through `error.ts`. The rules
are written without naming the files; they apply to any code that
takes the same shape.
Combined with 0001-0007, the project now has a ten-rule set that
covers mindset (0001), structure (0002-0003), runtime discipline
(0004-0005), explicit assumptions (0006), reading order (0007),
type discipline (0008), extension discipline (0009), and runtime
boundary discipline (0010). Each rule is independent in grain;
none duplicates another. Together they form the project's coding
charter.
A small, durable convention: every file in this repository is named in kebab-case. No camelCase, no PascalCase, no snake_case. The filename says nothing about the contents; the casing says nothing about the contents either. Inconsistency in casing tells the reader that someone made a choice that did not need to be made. The rule lists the explicit exceptions (`index.ts`, tool-mandated names like `.gitignore`/`tsconfig.json`/`vitest.config.ts`) so that the review-time signal is unambiguous. New exceptions are added to the rule; they are not discovered in PR review.
Three changes in one housekeeping pass:
1. Each rule that lacked code examples gains a bad/good pair.
0001 illustrates the no-shortcut, no-any, and no-silent-failure
invariants with concrete code. 0002 illustrates the
single-mega-file and syntax-based-split failures. 0003
illustrates the single-caller-helper-as-shared-utility smell
with a before/after. 0004 illustrates a swallowed try/catch
for a non-existent scenario. 0006 illustrates a choice
without the four-question justification vs one with. 0011
illustrates the case-insensitive-filesystem trap and a
uniform-kebab-case folder. 0005, 0007, 0008, 0009, 0010
already had examples and were left untouched.
2. Each rule gains a 'See also' section that links to
neighbouring rules. The cross-references that were implicit
(0001 mentions invariants 4 and 7 without pointing at 0004
and 0008; 0002/0003 share concerns but did not reference
each other; 0005/0007 are complementary; 0010 depends on
0006 and 0008) are now explicit.
3. A new INDEX.md sits at the top of rules/ and gives a
one-sentence summary of each of the eleven rules, an
ordered reading path for new contributors, and the
cross-reference convention.
A small, durable convention: shapes in this codebase are declared with `type`, not `interface`. The rule lists the three documented exceptions (declaration merging, class implementation of open shapes, host type augmentation) and the mechanical conversion procedure. Every other shape — value description, union, intersection, function signature, conditional, mapped type — is `type`. The rule captures why: `type` is more expressive, it has one declaration site, and it is the union's natural home. The choice between the two keywords is not a type-system choice; it is a convention choice, and the rule makes the convention explicit. INDEX.md updated with the twelfth rule.
Rule 0008 had two violation illustrations but no clear
corrections. This pass restructures the section into proper
bad/good pairs and adds a third, positive example of a single
legitimate cast at a named boundary.
The two bad/good pairs now show:
- Bad: chained cast `as unknown as Record<...>` on a value the
author controls. Good: one cast at the constructor boundary,
property assignment through the declared type.
- Bad: single cast `error as Record<...>` in business logic.
Good: the cast moves into a named `getFactory` accessor; the
business logic is honest about what it knows.
The positive example shows a single cast at a JSON-parse boundary
followed by a guard. This connects 0008 to 0004: a cast at a
boundary without a guard is 0004's smell; a chain of casts is
0008's. The two rules compound cleanly instead of overlapping.
Adds a 'What senior practitioners say' block to rule 0008 with
direct quotes from three sources, each with author, date, and
publication. The quotes cover the three positions that operationalise
the rule:
- Darryl Edwards (Code Krispies, June 2024): chained casts take
away the compiler's reasoning power; the author is substituting
their own judgment for the tool's.
- Maina Wycliffe (All Things TypeScript, October 2023): a cast is
a responsibility transfer from compiler to author; in business
logic, that transfer is undocumented.
- Anton Beluzhenko (JavaScript in Plain English, April 2024):
boundary values are unknown by definition; the cast at the
boundary is honest only when paired with inference.
The three sources converge on the rule's operational form: a
single cast at a boundary paired with a guard is acceptable; a chain
of casts is never acceptable. The block also reinforces the
distinction between rule 0004 (a cast without a guard) and rule
0008 (a chain of casts).
Adds a 'What senior practitioners say' block to rule 0004 with
direct quotes from four sources, each with author, date, and
publication:
- Aziz Kale (Dev Genius, July 2026): the reframe — 'why was
this value allowed to enter the system as null in the first
place?' becomes question 0 in the guard checklist, the gate
before the four-step procedure.
- Miguel Pizza (Maintainable TypeScript doctrine): the
operational form — 'if the type says it's not null, trust
the type. If the type is wrong, fix the type.'
- Vladimir Khorikov (Enterprise Craftsmanship): the repetition
smell — five guards across five methods are one domain
invariant expressed five times.
- Jim Bird (Building Real Software, March 2012): the cautionary
tale — a system saturated with guards becomes unmaintainable;
the fix is trust boundaries, not more guards.
The four sources converge on the operational form of the rule:
guards belong at the boundary between trusted and untrusted;
inside the trust boundary, the type system is the defence.
Three changes that make the Pizza quote the slogan of the project:
1. The quote is now an epigraph at the top of rule 0004 — the
reader who opens the rule sees the principle before the
checklist.
2. Rule 0001 (Project Mindset) gains a 'The trust-the-type
principle' section that connects the quote to the ten
invariants. Every invariant is now anchored to a single
sentence; the quote is the operational summary of the
mindset.
3. The INDEX carries the quote as the project slogan, with a
pointer to 0001 (operating principle) and 0004 (runtime
operationalisation).
The three locations — rule body, mindset anchor, project index —
make the principle the through-line of the entire rule set. A
new contributor who reads only the INDEX sees the quote; a
contributor who reads only rule 0004 sees the same quote; a
contributor who reads the mindset sees how the quote connects to
every invariant. The principle is no longer a citation; it is the
project's voice.
The original rule listed diminutives to ban and exceptions in two
lists that contradicted each other: the body banned 'DFS, JSON,
URL, id' while the exceptions section kept 'URL, JSON, HTML, CSS,
API, HTTP, ID'. This commit fixes that contradiction and grounds
the rule in senior practitioner consensus.
Two structural changes:
1. Diminutives are now classified into three categories by
source, not by length:
- Language-standard words (id, url, json, html, css, api,
http, cli, sdk, uri) — words in the working vocabulary;
the rule does NOT ban them.
- Mathematical and conventional names (i, j, k, n, T, U,
V) — older than any project; kept only within their
convention's context (loop bodies, generics).
- Project-internal diminutives (mgr, ctx, arr, fn, cb, dfs,
bfs, usr, cfg, evt) — the rule bans these.
2. A 'What senior practitioners say' block grounds the rule in
three voices:
- Julio Merino (2013): the strictest position — abbreviating
is a tax on every reader; spell words out.
- Google TypeScript Style Guide: the calibrated position —
abbreviating what is ambiguous is banned, what is standard
is kept.
- Keegan Donley (2023) + Russ Cox (2010): the conventional
exceptions — i, T, and standard words have meaning density
that long names cannot replicate in their context.
For the independent data structure invariant, the rule cites
Stepanov and Musser's 1994 'Algorithm-oriented Generic Libraries',
the canonical source for parameterising algorithms by container
access operations rather than by container representation. The
'Stack<T> reusable across BFS, DFS, undo-log' invariant in this
rule is the TypeScript-level restatement of Stepanov's
'Sequence<T> parameterised by iterators' insight from three
decades ago.
The rule now distinguishes source (the three categories) from
length (irrelevant) and explains why the source matters more than
the length.
A small, durable convention: bare '-er' suffixes as standalone
names (Manager, Service, Handler, Controller, Helper, Writer,
Reader, Converter, Validator, Router, Dispatcher, Observer,
Listener, Sorter, Encoder, Decoder) are refused. The rule names
three acceptable patterns in increasing order of preference:
1. Bare job title — refused. The class has no focal
responsibility; the suffix is the only content of the name.
2. Qualifier plus job title (CancelOrderHandler) —
acceptable. The qualifier says what is handled; the suffix
names the role.
3. Entity name (OrderCancellation) — preferred. The name
describes what the thing is, not what it does for the caller.
The rule is anchored in senior practitioner consensus: Bugayenko
(2015) on the philosophical ground (suffix names what the thing
does for the caller, not what it is), Muc (2008) on the
operational reading (suffix proxies responsibility count),
Lavoie (2019) on the quantified cost (six methods, thirty unit
tests in one file), Bogard (2018) on the counter-example
(qualified suffixes are fine).
INDEX.md updated with the thirteenth rule.
The first version of rule 0013 listed three patterns in
increasing severity: bare suffix (refused), qualified suffix
(kept), entity (preferred). On review, the qualified pattern
(`CancelOrderHandler`) is still role-naming, not entity-naming.
The qualifier does not turn a role into an entity; it only
makes the role harder to spot.
This commit collapses the three patterns to two:
- **Suffix as name content** (`Manager`, `Service`,
`Handler`, `CancelOrderHandler`, `UserCreationService`) —
refused, with or without a qualifier.
- **Entity name** (`OrderCancellation`, `ValidatedPayload`)
— only accepted shape.
The 'How to refactor' section now says the smallest acceptable
change is to drop the suffix entirely, not to qualify it.
`CancelOrderHandler` becomes `OrderCancellation`; the method
on the entity is the action (`cancel`).
Bogard's citation is reframed: his position is the most permissive
of the four sources, and the rule explicitly does not follow it.
The four sources still converge on the **diagnosis** (suffix is a
smell); the rule picks the strictest **threshold** (no suffix,
period).
INDEX.md updated with the tightened summary.
The public API of this codebase exports functions, not classes.
A class is a detail of internal implementation; the consumer
never instantiates it, extends it, or imports it by name.
The rule captures the principle in three invariants:
1. An export is a function (or a type, or an Object.freeze'd
value). A class is NOT an export.
2. A consumer creates an entity by calling a function
(createGroup, group), not by `new`-ing a class.
3. The consumer sees the type of the constructed entity, not
the class. The class is a private symbol; the type is public.
The rule is anchored in Dan Abramov's senior position: 'Resist
making classes your public API. If you expose them, people will
inherit from them in all sorts of ways that make zero sense to
you, but that you may break in the future.' Salcescu adds the
encapsulation argument: a factory function closes by default; a
class opens by default. The rule picks the closed default because
the consumer never needs the open one.
Classes are still permitted internally for state encapsulation
(Stack, Error, Group), but they live behind the factory
function. The function is the boundary; the class is behind it.
TypeScript's structural typing means the consumer can still
duck-type against the public type without knowing the class
exists.
INDEX.md updated with the fourteenth rule.
A primitive (string, number) is typeless at the semantic level.
A Message is a Message; a UserId is a UserId. The rule says: every
value that represents a domain concept is typed as a
domain-specific type, not as a primitive.
The rule captures four patterns for the domain type, chosen by
what the value carries:
- Branded type (identifier, scalar): `type AccountId = string &
{ __brand: 'AccountId' }`.
- Record type (shape with fields): `type Message = { content;
type; priority? }`.
- Discriminated union (multiple shapes): `type Event = { kind:
'click' } | { kind: 'key' }`.
- Branded record (identifier with metadata).
The rule is anchored in three sources. Goldberg (Learning
TypeScript, 2024) on the structural-typing limitation that
branded types solve. Ferreira (2022) on the time dimension: a
primitive bug appears six months later when the consumer has
forgotten the convention; a domain type shifts the error to
compile time. Foth (SE, 22 votes) on the reader's economy:
the alias adds nothing if the reader still sees a primitive.
The rule picks the strictest threshold: primitives cross
boundaries only at conversion functions (`parseUserId(raw):
UserId`). The conversion validates the contract the primitive
cannot. After the conversion, the primitive is gone from the
codebase's vocabulary.
INDEX.md updated with the fifteenth rule.
Every rule now carries a final 'Sources' section that lists the
external references that informed it, or explicitly states that
the rule is a synthesis of the project's own working experience.
Two patterns emerge:
1. Rules anchored in senior practitioner consensus (0001, 0004,
0005, 0008, 0013, 0014, 0015) cite the specific authors and
publications that informed each invariant. The citations are
the same ones used in the body; the Sources section makes
them discoverable in one place per rule.
2. Rules that synthesise project experience without an external
anchor (0002, 0003, 0006, 0007, 0009, 0010, 0011, 0012)
explicitly say so. The Sources section names the conventions
or heuristics the rule draws on (the Rule of Three, the
Open/Closed Principle, TypeScript's own documentation) and
states that no single external reference anchors the rule.
The discipline is the same in both cases: a reviewer who wants
to challenge the rule can read the Sources section and know
where to push back. Rules without external references are
honest about being internal; rules with external references are
explicit about which voice is the loudest.
The Sources section sits at the end of each rule, after 'See
also'. It is the last thing the reader sees before 'Exceptions',
so the rule ends with the empirical ground, not the
implementation detail.
The example in rule 0015 had inline literal unions ('text' | 'image'
| 'audio' and 'low' | 'normal' | 'high'). Extracting them as
named types (MessageType, MessagePriority) is more idiomatic and
captures the rule's own principle: domain types are extracted
even when the primitive is a literal, because the literal set is
itself a domain concept.
The named unions make the example reusable: other types that
need the same set of literals (a notification filter, a draft, a
summary) reuse MessageType instead of repeating the union. The
literal strings now live in one place; adding a new value is one
line in the type and the compiler enforces that every consumer
handles it.
The branded-type example showed branding two different identifiers
(AccountId, CustomerId) as a 'good shape', but did not warn
against the failure mode: branding a single identifier with no
internal structure.
The revision adds a contrasting example: branding a single id
(OrderId) is friction without value. The brand forces every
consumer to construct the branded type via 'as' or a wrapper,
and the compiler was never going to confuse OrderId with another
id type if there is only one.
The rule now reads:
- Branded types are justified when the codebase has two or more
IDs of the same primitive type that must not be confused.
- When the codebase has only one identifier per domain value,
the primitive is the right shape; the type name is the contract.
This keeps the rule honest about its own scope. A reviewer who
sees a brand on a single id can now cite the rule to push back.
A function name's verb must encode the transformation, the return
type must encode the result. Generic verbs (process, convert,
validate, transform, handle, do, make, perform) fail at least one
of the three questions; they are refused as the verb of a
function name when a more specific verb is available.
The rule is anchored in four sources:
- King (Parse, don't validate, 2019): a verb encodes the
contract. 'validate' returns a boolean; 'parse' returns the
parsed value. The rule applies the principle to naming.
- Lu\u00f6bke (No Generic Terms, 2021): the forbidden-words list
that the rule extends with function-name-specific generic
verbs (parse, convert, validate, transform).
- Theken (Named Generic anti-pattern, 2010): targets types;
this rule targets verbs. Both are instances of 'a name that
adds zero information'.
- Karlton (via Lu\u00f6bke): the canonical formulation of the
naming problem.
The test for a good verb: can the reader tell what the function
does from the name alone? If not, the verb is generic. Four
questions on the PR (transformation, return, input, testability)
catch the smell before review.
INDEX.md updated with the sixteenth rule.
Twelve inconsistencies were identified between the architecture rules and supporting documents (INDEX.md, README.md). This commit resolves each one with the minimum edit that aligns the text with the doctrine already expressed elsewhere in the ruleset. No new doctrine is introduced; no existing constraint is weakened. INDEX.md & README.md - Count the ruleset correctly (sixteen, not eleven). - Replace stale 0015 summary in INDEX with the refined rule: brands apply only when a second identifier of the same primitive would otherwise be confused with it. - Mark 'Enforced via CI' as a target state (no rule has migrated). - Replace README filename examples with the actual filenames. - Soften the 'rules must be short' constraint: length follows the doctrine, not the other way around; the boundary between rules and process documents is purpose, not length. 0001 / 0003 / 0002 / 0005 - Disambiguate the three extraction thresholds the codebase applies: one-caller inline (0005), two-concern move (0003), three-case abstraction (0001 invariant 4 — Rule of Three). Add a 'Thresholds at a glance' table in 0003 and cross-link the rules so a reader cannot apply the wrong number to the wrong decision. 0016 - Remove 'run' and 'execute' from the generic-verb blacklist. They are orchestration verbs (runPipeline, executeSteps) and the exception below is their canonical evaluation site. Document the carve-out explicitly so the rule no longer contradicts itself. 0013 / 0012 / 0014 - Reconcile the three naming/API rules. 0013's 'good shape' example now states that the class is internal per 0014. 0012 and 0014 cross-link to each other: 0012 picks 'interface' for the open-shape extension point; 0014 hides the class that implements it. The two rules cooperate rather than appearing to compete. 0010 / 0015 - Document the rule 0015 carve-out for environment values. Environment values are runtime configuration, not domain concepts; they remain primitives inside the accessor. The carve-out has a sharp boundary: the moment a value crosses the accessor and enters the domain, rule 0015 applies in full.
martyy-code
force-pushed
the
architecture/internal-documentation
branch
from
August 11, 2026 12:00
85dffa0 to
5016fa3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Scaffolds
docs/engineering/architecture/as a new top-level home for durable architecture guidance, distinct from the existing:docs/internal/— process documents (release runbooks, PR authoring, implementation tasks).docs/learnings/— postmortems and tribal knowledge.Two artefact types live in the new tree:
architecture/decisions/— Architecture Decision Records (one file per decision, status lifecycle: proposed/accepted/superseded/deprecated).architecture/rules/— Standing rules (always-on, enforced through review or CI; lifecycle: active/enforced/superseded).Both subfolders ship a README that defines the naming convention (
NNNN-short-slug.md, monotonic), the status lifecycle, and the authoring format.Why
Without a dedicated home, durable architecture rules either get mixed into process docs (where they read as runbooks) or scattered into comments and PRs (where they drift). The repo has plenty of
docs/internal/process material but no place for timeless principles that survive across releases. The new tree is the place those go.What ships in this PR
Three rules, written to remain readable in five years by a contributor who never saw the current codebase:
0001 — Project Mindset: Excellence by Default
Ten absolute invariants. No shortcuts. No conscious debt. No
any. No silent failures. No compiler bypass. No unjustified dependencies. Optimise for the reader. Excellence is silent. No exceptions.0002 — File Separation: by Concern, not by Syntax Kind
Within a concern, types / constants / functions split into their own files (
types.ts,constants.ts, verb-named operation file). Across concerns, no shared barrel that re-exports types or helpers from multiple concerns. Captures both failure modes: the mega-blob file and the globaltypes.ts/utils.ts.0003 — File Placement: Decide Before You Create
Every new file justifies its location before it exists. A single-caller helper stays next to its caller; extraction requires a second real use site. Captures the failure mode of premature centralisation.
Notes
.github/CODEOWNERSis not updated in this PR — the rules are enforced through code review, not through file ownership. If we want CI enforcement for specific invariants (e.g. noany), that becomes a separate rule in a separate PR.