Skip to content

docs(arch): scaffold engineering docs and seed three rules - #70

Merged
codewizdave merged 21 commits into
stagingfrom
architecture/internal-documentation
Aug 11, 2026
Merged

docs(arch): scaffold engineering docs and seed three rules#70
codewizdave merged 21 commits into
stagingfrom
architecture/internal-documentation

Conversation

@martyy-code

Copy link
Copy Markdown
Contributor

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 global types.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

  • The rules reference no specific file in the codebase, no specific dependency version, and no specific commit. They are written to be timeless per the project's working principle.
  • The README files in each subfolder describe the format of the artefacts (one ADR per decision, one rule per constraint), not the content of any specific decision. When decisions and rules land, they will reference each other by NNNN, not by file path.
  • .github/CODEOWNERS is 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. no any), that becomes a separate rule in a separate PR.

claude and others added 21 commits August 11, 2026 10:47
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
martyy-code force-pushed the architecture/internal-documentation branch from 85dffa0 to 5016fa3 Compare August 11, 2026 12:00
@codewizdave
codewizdave merged commit 4ced3d6 into staging Aug 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants