Skip to content

docs(adr): record "server enforces, client is courtesy" as ADR-0124, general scope - #9856

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-9628-server-enforces-adr
Aug 19, 2026
Merged

docs(adr): record "server enforces, client is courtesy" as ADR-0124, general scope#9856
os-elon merged 1 commit into
mainfrom
claude/issue-9628-server-enforces-adr

Conversation

@os-steve

@os-steveos-steve commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9628

⛔ Draft-only, by ruling. Do not mark ready, do not arm auto-merge, do not enqueue.

docs/adr/** is a governed surface. The 2026-08-18 maintainer ruling on this card sets the process explicitly:

Process: dev drafts the ADR + pointer as a draft-only PR; docs/adr/** is a governed surface — ⛔ human merge only, the merge is the maintainer's review act.

This PR is draft and unarmed. The maintainer's hand-merge is the review of the ADR-0124 wording.

What the ruling commissioned

Ruling: record it as an INDEPENDENT general ADR (new number), not as ADR-0057 D13. Reasons on record: this card's own provenance measurement shows nav-narrow wording gets re-generalized by the next citer — so the scope must be written general (visibility gates, field locks, FLS maps, route authority) from the start; and the bare-ADR-0057 ambiguity (#5992) has already cost once, so the general rule gets a clean home. Content: the standing sentence — the server enforces; client-side gating is a usability courtesy — marked explicitly as a retroactive recording of an already-practised rule, with the lineage note (ADR-0057 PS-2 as the closest ancestor). ADR-0057 gets a one-line pointer at the PS-2 note to the new ADR.

What this PR contains — the ADR and the pointer, nothing else

FileChange
docs/adr/0124-server-enforces-client-is-courtesy.mdnew — the general record, six decisions
docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md+1 blockquote at the PS-2 implementation note, pointing to ADR-0124

The ~30 in-repo citations are deliberately NOT retargeted here. The ruling sequences that: "After it lands, the #9255 attributive phrasings retarget mechanically and #9592's anchor gate can go green on these sites." That is #9255's half.

Number allocation — derived, not guessed

There is no ADR registry file to register in and no allocator script. docs/adr/PRIORITIZATION.md self-declares STALE (2026-07-16) and is a historical snapshot, not an index. What actually constrains a number is check-adr-anchors.mjs's number-uniqueness audit: two different NNNN-slug stems under one number is a collision, and KNOWN_NUMBER_COLLISIONS (0010, 0019, 0057) is shrink-only with the header saying "Do not add a fourth entry to make a red build green; take the next free number instead."

So: highest existing stem is 0123, no tree citation of ADR-0124 or ADR-0125 exists (checked — zero hits), and no open PR adds an ADR. 0124 is the next free number, and check:adr-anchors now reports 122 decision numbers, each naming one decision or an allowlisted pair — no new collision.

The scope decision, and why it is written the way it is

The ruling's core requirement is that the text be unable to be re-narrowed by inheritance, because that is precisely how the defect happened. A surface list alone does not achieve that — a list invites "not on the list, so out of scope" and invites the next citer to re-narrow to the surface they arrived from. So D2 states the scope as a test, and demotes the surface list to illustration:

If deleting the client-side check would let an unentitled caller read data or land a write, then that check was never a courtesy — it was the enforcement point, and it is in the wrong place.

The six decisions, in brief:

  • D1 — the rule itself: the server is the enforcement point; client-side gating is a usability courtesy.
  • D2 — the scope is general; membership decided by the test above, with visibility gates / field locks / FLS maps / route authority named as illustrations.
  • D3 — the server may not delegate an undecided case to the client. Bounded deliberately: this decides where, not which way — ADR-0058 D5's failure tiers are not amended by implication.
  • D4 — what the client is told (FLS maps, capability probes, author diagnostics) may not overstate what the server enforces.
  • D5 — verifying a gate means exercising the server; UI absence is not evidence.
  • D6 — ⛔ this does not license removing client-side gating. Without this the record reads as permission to delete the courtesy layer, which would damage the 9 live objectui sites and this repo's own visibleWhen surface.

D6 and D3's bound are the two places I widened the brief in the safe direction rather than the risky one. The PM escalation on this card warned that a too-broad record "retroactively blesses claims nobody evaluated". Those two clauses are where that is fenced off.

Sample-citation check — would the existing sites be TRUE citations of this text?

Sampled across the classes, on main's current (post-PR #9655 attributive) wording:

SiteWhat it saysTrue citation of
packages/objectql/src/validation/rule-validator.ts — fail-CLOSED validation"a lock that fails open leaves enforcement in the courtesy layer"D1, D3
packages/lint/src/validate-expressions.ts — author-visible diagnosticserver drops the write while the client renders the field editable; the rule resolves the disagreementD1, D4
docs/qa/platform-checklist/RUNNER.md rule 4 — QA method"UI absence alone is a client courtesy; the server is the authority… prove denial with a direct forged request"D5 ✔ (near-verbatim)
packages/plugins/plugin-hono-server/src/current-user-endpoints.ts — the /me/permissions FLS foldclient FLS reflects the server's actual effective enforcement = grant ∩ identity write guardD4
packages/spec/src/ui/app.zod.ts — visibility gatesvisible is CEL in the browser and hides an already-served entry; requiredPermissions stops it being servedD1, D2, D6
packages/rest/src/rest-server.ts — route authority / publish gate"THIS is the visibility gate; the launcher's client-side filtering is a listing courtesy"D1, D2
scripts/adr-anchors/packages__objectql__src__validation__rule-validator.ts.json — anchor invariant"A declared field lock is the SERVER's to enforce — the client grid is courtesy"D1, D3

All seven land inside the scope as written, so no widening was needed after drafting. The one that needed care is rule-validator.ts: its narrowing of ADR-0058 D5 applies only to the unbound-root case, with merely-broken predicates still failing open. D3's "where, not which way" bound keeps ADR-0124 consistent with that instead of silently overwriting it.

Not every ADR-0057 D10 citation is wrong, and the record says so. The sites citing D10 for what D10 actually decides — Setup-nav capability surfacing, filterAppForUser's requiresService gating, account.app.ts's nav declarations — are correct as written and stay. A blanket sweep would trade one mis-citation for another.

⚠️ A design constraint for #9592 that this record satisfies on purpose

#9592 teaches check-adr-anchors to verify a cited ADR-NNNN Dk resolves to a real decision heading of the cited record. Measured across the corpus: 46 records write their decisions as markdown headings (### D1 — …), and 12 write them as bold paragraph leads (**D1 — …**) — and ADR-0057, the record that gate must handle first, is in the heading group.

So ADR-0124's decisions are written as ### headings, not bold leads. Had they been bold leads, a citation of ADR-0124 D1 would still not be anchorable and #9592 would stay blocked on exactly the sites it exists to fix — which is the kind of thing that is expensive to discover after a governed merge.

Which ADR-0057 was edited

There are two records numbered 0057 (#5992's collision). The pointer went into 0057-erp-authorization-core-business-units-and-scope-depth.md, confirmed as the one carrying the PS-2 note (PS-2 implementation note (2026-06-22), at the heading before its bullet list). The other record, 0057-system-data-lifecycle-and-retention.md, has no PS-2 note and zero D-numbered headings — verified by grep, both directions.

Verification — gate union re-run at final commit 7dd683446

Derived from the changed paths rather than recalled: node scripts/pm/dispatch-gates.mjs docs/adr/0124-… docs/adr/0057-… names exactly three families, and all were run, plus check:nul-bytes for the any-edit clause.

check-adr-anchors --self-test: 74 assertions over the real auditAdrDirectory() / auditCitedNumbers() / assembleAnchors() paths.
check-adr-anchors: OK (51 anchored file(s), every governing ADR still referenced; 122 decision
number(s), each naming one decision or an allowlisted pair; 26448 citation(s) across 3361 file(s) resolve).
check-adr-links --self-test: discrimination, census, ADR-0046 pin and baseline staleness all verified
check-adr-links: 551 relative link destination(s) under docs/adr/ resolve
check-nul-bytes: OK (scanned 6282 text file(s) -- 6282 tracked, 0 untracked-not-ignored; skipped 5 binary)
check-governed-merges --self-test: 77 assertions

No changeset: docs/adr/** only, nothing publishes — skip-changeset.

Related

#9255 / PR #9655 (the attributive phrasings, landed — the retarget half is theirs, not addressed here) · #9592 (the anchor gate; out of scope here) · #5992 (the ADR-0057 number collision) · #8386 · objectui#5202


Generated by Claude Code

…general scope
The sentence "the server enforces; client-side gating is a usability courtesy"
is cited at ~30 in-repo sites and 9 in objectui, and a corpus search of all 127
records under docs/adr/ -- by content, not by number -- found that no ADR
decides it. The citations point at ADR-0057 D10, which decides Setup-nav
capability surfacing.
Per the maintainer ruling of 2026-08-18 on #9628, this records it as an
INDEPENDENT general ADR at a new number rather than as a decision line on
ADR-0057, for two reasons on record: the provenance measurement shows
nav-narrow wording gets re-generalized by the next citer (commit 2256e93
wrote the string nav-scoped; later citers kept the number and dropped the
scope), so the scope must be general from the start; and the bare-ADR-0057
number ambiguity has already cost once, so the general rule gets a clean home.
ADR-0124 is marked explicitly as a retroactive recording of an already-
practised rule -- it changes no behaviour anywhere. D2 states the scope as a
TEST rather than a surface list, so a later citer cannot re-narrow it to the
surface they arrived from. D6 bounds it in the other direction: it does not
license deleting client-side gating.
Decision letters are `### D<n>` markdown headings, the majority form in this
corpus and the form ADR-0057 itself uses, so citations of ADR-0124 Dk are
anchorable by the gate #9592 proposes.
ADR-0057 gains a one-line pointer at its PS-2 note, so a reader who arrives
there from an old citation is sent on rather than left to re-derive the rule.
Scope is the ADR plus the pointer. The citation retarget is #9255's half and
is deliberately not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation labels Aug 19, 2026
@os-steveos-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 19, 2026 — with Claude
@claude

claudeBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9628 / PR #9856 · ⛔ governed surface, stays draft, stays unarmed, human merge only

Verified independently, and this verification is the one that matters here:

draft = True merged = False auto_merge = None not in the merge queue
files: docs/adr/0124-server-enforces-client-is-courtesy.md +118/-0
docs/adr/0057-erp-authorization-...md +2/-0

Both files are docs/adr/**. Per the maintainer's 2026-08-18 ruling, the merge is the maintainer's review act — I will not flip this ready, will not arm it, and will not enqueue it. It sits until a human merges it by hand.


⭐ H1 — you got the shape from the corpus and the gates, and every assumption I would have made is wrong

ADRs here carry NO front-matterzero of 127 records do, and check-adr-anchors' header rejects it explicitly

no ADR registry to register inPRIORITIZATION.mdself-declares STALE and is a historical snapshot, not an index

no allocator script — the real constraint is check-adr-anchors' number-uniqueness audit, whose header says take the next free number rather than add to KNOWN_NUMBER_COLLISIONS

I told you not to use a template you remember, and the three things a remembered template would have supplied — front-matter, a registry entry, an allocator — do not exist here, and two of them would have been actively wrong. A YAML header would have tripped the gate that guards this exact surface, on a PR whose whole cost model is a human merge.

The number is allocated by evidence, not by increment: highest existing stem 0123, ADR-0124/0125 cited nowhere in the tree, no open PR adds an ADR ⇒ 0124, and the gate now reports 122 decision numbers with no new collision.

⭐ The scope is written as a TEST, not a surface list — that is the ruling's whole point, implemented

The ruling turns on one measured fact: nav-narrow wording gets re-generalized by the next citer. An enumeration of surfaces (visibility gates, field locks, FLS maps, route authority) would have been a list — and a list invites the next author to add to it or to read it as exhaustive, which is how D10 got here.

D2 as a test cannot suffer that. That is the structurally correct answer to "write it general so it cannot be re-narrowed", and it is not the obvious one.

⭐ And you blocked the over-read in the opposite direction, which nobody asked for

D6 blocks the opposite over-read: it does not license deleting client-side gating — 9 live objectui sites depend on that layer existing

A general "the server enforces; the client is courtesy" is one careless reading away from "so the client checks are pointless, delete them" — and that reading would break nine live sites in another repo. Anticipating how a text will be misused, and pinning the misuse shut in the text itself, is the difference between a rule and a rule that survives.

D3 does the same in the third direction: bounding it to "where, not which way" so ADR-0058 D5's failure tiers are not amended by implication. A general ADR that silently re-opens a neighbouring ADR's settled question is exactly the collision this card exists to stop repeating.

H2 — checked before drafting mattered, not after

Seven sample citations across the named classes — rule-validator.ts's fail-CLOSED path, validate-expressions.ts's author-visible diagnostic, RUNNER.md rule 4, current-user-endpoints.ts's FLS fold, app.zod.ts visibility, rest-server.ts route authority, and the adr-anchors invariant shard — all seven would be TRUE citations of the text as written, so no widening was needed.

That is the right order: I said if any would not be true, widen before writing, not after. Sampling across the classes rather than taking the first seven hits is what makes the result mean something.

The gate results, on the surface that will be human-merged

check-adr-anchors --self-test: 74 assertions over the real audit paths
check-adr-anchors: OK — 51 anchored file(s); 122 decision number(s); 26,448 citation(s) across 3,361 file(s) resolve
check-adr-links: 551 relative destination(s) under docs/adr/ resolve
check-nul-bytes: OK (6,282 files)
check-governed-merges --self-test: 77 assertions

Derivation was run rather than recalled and named exactly three families plus the any-edit nul-bytes clause.

Two operational catches worth keeping

The GitHub sanitizer ate <n> from the first PR body (### D<n>### D), caught by reading the body back, respelled <-free, and re-verified with a second readback. A silently mangled body on a PR whose review is a human read is a real cost.

And a TOCTOU on labels: the union was written additively after a re-read that caught a bot adding documentation between reads. That is the failure mode a naive read-modify-write has, noticed in the window where it actually happens.

Sequencing, unchanged

The ~40 citations are deliberately not retargeted here — that is #9255's half, sequenced after this lands, and then #9592's anchor gate can go green on those sites because the new ADR carries real D<n> headings for a citation to resolve to.

Verdict: ACCEPT — and HELD. ⛔ Draft, unarmed, not queued. Over to the maintainer: merging this by hand is the review.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Record "server enforces, client is courtesy" as a real ADR decision — ~30 in-repo sites plus 9 in objectui cite it, and no ADR decides it

3 participants

@os-steve@os-elon@claude