Documents / Decisions / adr-bc45b0bb1023
One max_body_chars per type, and a flag that picks the tier it acts at
One max_body_chars per type, read by both tiers; max_body_chars_enforce decides whether it refuses the write or only reports it, so the Tier 2 smell is never a second number.
Context¶
max_body_chars shipped as a Tier 2 number: lint --deep read it, printed
scope-creep, and nothing stopped a document from growing. That is the right
shape for a smell — a register split in half is two half-registers, which is
the argument issue-5d6a5e854d11 settled — but it leaves a store with no way to
say "documents of this type do not get longer than this", enforced.
The tier rule (arch-ad342aae8293) says a heuristic is never promoted to a hard
error, and a naive reading of "make max_body_chars blocking" does exactly
that.
Decision¶
One number, read by both tiers. max_body_chars_enforce picks the tier it
acts at.
max_body_chars: N means Tier 0 refuses a write that would leave the body over
N and longer than it already was, with BodyTooLargeError — its own exit
code, 9, because the caller's move is to split the document rather than to
rewrite a field, and a script branching on ValidationError's 2 would retry the
same body forever. Tier 2's scope-creep reads the same N and still only
suggests.
max_body_chars_enforce: false is exactly the behaviour the key had before it
gained a tier: nothing is refused, lint --deep still names the document, and
the write carries body_limit_notice back to the caller — on stderr for the
CLI, in the payload for MCP. Set without a positive max_body_chars to relax it
is a SchemaError, since a key that reads as configuration and configures
nothing is worse than one refused.
This is not a promoted heuristic. The number is the type's own — absent, Tier 0 does nothing at all — and so is the tier it acts at.
The second key that was built and dropped¶
The first implementation split them: max_body_chars as the ceiling,
advisory_body_chars inheriting the Tier 2 role. It buys exactly two
configurations — "suggest at 8000, refuse at 12000", and "refuse at N, never
suggest" — and costs:
- A rename for every store that already set the key, plus a silent change to
what
scope-creepreports for that type. - A second number that can only disagree with the first. A type that refuses a write at N has already said what "too long" means for it.
The early-nudge case is the only real argument for the split, and it is weaker
than it looks: lint --deep is opt-in and off the write path, so the nudge
arrives when somebody runs the linter, not when the document grows.
What holds the Tier 0 half up¶
No default, ever. Absent means no ceiling, and no shipped type declares one.
The schema merges core → profiles → inline on every command, so a default
would begin refusing writes to documents that were legal when they were written,
on a release with nothing in git diff to point at — the argument the
schema-drift warnings already make, one tier louder.
0 still turns off both halves, which is what it already meant: no ceiling,
and never "too long". This store's reference line is untouched by this change.
Growth is the trigger, not size. A document already over the limit still
takes --set-title, --status, --set-tags, --type and a shorter
--replace-body. Refusing every write to an oversized document blocks the one
edit that fixes it and leaves hand-editing markdown as the only repair, which
the CLI-is-the-only-write-path thesis forbids. The ceiling stops growth; the
lint is what reports the backlog that is already there.
The mechanical rewrites are exempt. A tag rename, a forced delete's
edge-strip and check --fix do not run through add/update and so never
reach the check. One refusing halfway through a corpus-wide rewrite leaves it
half-applied, and a repair that refuses to run is not a repair.
The loader refuses what it can name. A negative limit is every body's
ceiling — refused at load rather than refusing every write to the type one
command later. So is a non-boolean max_body_chars_enforce, and the dead
relaxation above.
Consequences¶
- A store opts in per type. Nothing changes for a store that does not, and no store has to rename anything.
- A store that set
max_body_charsas advice gets a ceiling at that number on upgrade. The recovery is one added line,max_body_chars_enforce: false, and it is not silent for long: growth is what is refused, so the first write that crosses says so by name. max_body_chars_enforcejoinsschema_shape.describe, socheckreports it once asschema-driftper type against an older baseline — measured on a store built by 0.26.0 — cleared byreindex.BodyTooLargeErrortakes exit code 9. The next error class takes 10.- The guards were verified by injection: dropping the growth carve-out, the
addcall site, the enforce flag, and the loader's two refusals each fail exactly one test.