cofferdam.invariants.toml — project-wide architectural spec
One canonical artifact for "what is this codebase supposed to be?" — read by humans, agents, and multiple checks at once. Promotes the per-check [layers] block from cofferdam.toml to a shared spec that also covers public-API allowlisting, frozen boundaries, and arbitrary forbid/require import rules.
File location
cofferdam.invariants.toml lives next to cofferdam.toml at the project root. Discovery walks up from the working directory until it finds the file or hits a .git entry — same rule as cofferdam.toml. Both files are optional and additive; you can ship one without the other.
Schema
schema_version = "1.0"
[layers]
infra = ["src/infra/**"]
domain = ["src/domain/**"]
app = ["src/app/**"]
[layers.allow]
domain = ["infra"]
app = ["domain", "infra"]
[public_api]
exports = ["package.json:exports", "src/index.ts"]
[boundaries]
"src/legacy/**" = { frozen = true, reason = "see ADR-0007" }
[invariants]
"no-direct-db-access" = { forbid_imports = ["src/infra/db"], from_layers = ["app"] }
"telemetry-required" = { require_imports = ["src/infra/telemetry"], from_layers = ["app"] }schema_version
MAJOR.MINOR (semver-flavoured). Accepted as integer (1 → treated as 1.0) or string ("1.0", "1.2"). The current version this build ships is 1.0. The field is honoured at load time — future versions the build doesn't understand are rejected with an upgrade message; past versions outside the deprecation window are rejected with a migration message. A spec without schema_version is loaded as 1.0 for backwards compatibility, with a one-time hint pointing at this section.
Full policy — bump rules, deprecation window, supported versions — lives in docs/schema-versioning.md.
[layers] and [layers.allow]
Identical shape to the [layers] block in cofferdam.toml. When both files declare layers, the invariants spec wins and the CLI emits a deprecation hint pointing at cofferdam.toml. Read by Design.LayerViolation.
[public_api]
exports is a list of "entry-point sentinels". Each entry is either:
- a relative path to a TS/JS file (
src/index.ts) — every export from that file is exempt fromDesign.OrphanExport, - a glob pattern (
components/ui/**/*.tsx) — every file whose project-root-relative path matches the pattern is exempt. Useful for vendored UI directories (shadcn/ui, etc.) where a single line covers many files. Standard glob metacharacters*,**,?,[…], and{…,…}are supported; an invalid pattern is silently skipped (the check still runs, the pattern just exempts nothing), or - a
package.json:<key>pointer (package.json:exports) — schema accepts it; resolution lands in a follow-up bead.
Example — exempt a vendored UI directory:
[public_api]
exports = [
"src/index.ts",
"components/ui/**/*.tsx",
]Read by Design.OrphanExport.
[boundaries]
Glob → boundary metadata. frozen = true marks the area as off-limits to new code; v0 stub-warns one finding per file matching the glob (Design.BoundaryFrozen), with reason echoed in the message. Per-file delta enforcement against a baseline lands in a follow-up bead.
[invariants]
Named forbid/require import rules, each fired independently:
forbid_imports— list of project-relative path prefixes (or bare specifiers likelodash). An import edge whose resolved path or source specifier starts with any prefix triggers a finding at the import statement.require_imports— list of prefixes that must be imported by every file infrom_layers. A file with no matching import receives one finding at its first import statement.from_layers— optional layer-name allowlist. When non-empty the rule applies only to importing files whose path falls into one of those layers (per the merged[layers]config). Empty means "applies to every in-project file".
Read by Design.InvariantViolation. All invariants share one check id — suppress per-line via // cofferdam-disable-next-line Design.InvariantViolation or globally with a severity override on the check.
[invariants.scripted]
When the forbid_imports / require_imports shape can't express a rule — file-level constraints, layer-conditional gates, predicates over both imports AND exports — declare a scripted invariant with the v1 predicate DSL.
[invariants.scripted."controller-test-pair"]
when = "file matches 'src/controllers/**/*.ts'"
require = "exists('tests/' + basename(file))"
message = "Every controller needs a test file under tests/"
[invariants.scripted."ui-no-localstorage"]
when = "file matches 'ui/**'"
forbid = "imports 'localStorage'"
message = "UI files must not touch localStorage directly"Each rule has four fields:
when(optional) — predicate that gates the rule. The rule only fires on files wherewhenevaluates true. Omit to apply to every file.require— predicate that MUST hold. The rule emits a finding when it evaluates false.forbid— predicate that MUST NOT hold. The rule emits a finding when it evaluates true.message— literal text surfaced on each finding.
Exactly one of require / forbid must be set. Setting both or neither is a config-load error.
DSL surface (v1). Boolean glue (and, or, not, parentheses) over comparisons; string concat with +; functions basename(...), dirname(...), exists(...). Operators:
| Operator | Purpose |
|---|---|
matches | gitignore-style glob match against a file path |
== / != | string equality on file.path / file.layer |
in '<layer>' | file resolves to the named layer |
imports '<spec>' | direct import edge to a module specifier or path |
transitively imports '<spec>' | transitive closure (direct-only in v1; full closure in cd-9hp.9) |
imports as type '<spec>' / imports as value '<spec>' | type-only vs value imports |
exports '<name>' | file exports a named symbol |
Subjects: file, file.path, file.layer. Namespaces core.* and ts.* are reserved in the grammar for future-domain expansion; v1 does not yet resolve them. Full grammar — including the EBNF and the list of error classes — lives in docs/dsl-grammar.md.
Fail-fast at config load. Every DSL string is parsed when cofferdam.invariants.toml is loaded. A malformed predicate is a fatal error pointing at the offending rule + field; the engine refuses to start. Bad scripts never reach file 4000 of the run.
v1 limitations.
- Findings carry the file path with line/col
1:1. Per-edge spans forimportspredicates are reserved for a future MINOR bump. messageis the literal string.{file}and friends from the grammar doc are forward-compat surface — v1 emits the message verbatim and reserves interpolation for v2.transitively importsevaluates direct edges only in v1 (graph closure ships with cd-9hp.9).
Read by Design.ScriptedInvariant. All scripted rules share one check id — suppress per-line via // cofferdam-disable-next-line Design.ScriptedInvariant or globally with a severity override on the check.
Migration from cofferdam.toml
Existing [layers] configuration in cofferdam.toml continues to work. To migrate:
- Create
cofferdam.invariants.tomlnext tocofferdam.toml. - Move the
[layers]and[layers.allow]blocks across. - Add
[public_api],[boundaries], or[invariants]as needed. - Remove
[layers]fromcofferdam.tomlto silence the deprecation hint.
The two files coexist for projects that don't yet need the broader spec.