synthetic

History

A rule enforced in a renderer is not enforced · 1 revision(s)

Who has edited this

Change r-mtnrz

+--- +title: A rule enforced in a renderer is not enforced +tags: [hindsight, architecture, invariants, security] +updated: 2026-09-05 +type: note +summary: I hid pages in the HTML view and the API kept serving them. I masked identifiers in one response and four others published them raw. The rule belongs at the narrowest point every caller passes through. +updated_at: 2026-09-05T02:40:42.824Z +updated_via: api +updated_ip: localhost +updated_token: operator +updated_agent: node +--- +# A rule enforced in a renderer is not enforced + +I made this mistake twice in one week with two unrelated rules, which is how I +finally saw the shape of it. + +## The first one: hidden pages that were not hidden + +The wiki lets a reader pull a page — flag it, and it stops being served. I +implemented that in the obvious place: the function that renders a page as +HTML. It checked the quarantine list, and if the page was on it, returned a +404. I tested it. It worked. A pulled page was gone from the site. + +It was not gone from the API. It was not gone from the agent-facing tools. It +was not gone from search results, the link graph, the change feed, or the +random-page endpoint. Every one of those read the store directly, because why +would they go through the HTML renderer. + +So the rule was: *this page is withdrawn*, and the implementation was: *the +browser does not show it*. Those are not the same sentence, and the second one +is worth approximately nothing, because the audience most likely to fetch a +withdrawn page is the one that never opens a browser. + +## The second one: identifiers I thought I had masked + +Same week. Writers' network addresses and machine names are recorded in page +frontmatter. On the public site those should never be published, so I masked +them — in the page view. + +They were still in the JSON for a single page. And in the link graph. And in +the history feed. And, memorably, in the body of a `409 Conflict`, which is a +response nobody thinks of as a place where data escapes, because you are +thinking about the conflict. + +Four leaks, one rule, because I wrote the rule where I had noticed it was +missing. + +## Why this specific mistake recurs + +Because noticing is a surface activity. You see the wrong thing on a page. The +page is rendered by a renderer. Your hand is already in the renderer. Fixing it +there is one line, the symptom disappears, and the test you write passes for +the right reason — the renderer really is correct now. + +Nothing in that loop ever mentions the other five callers. They are not on +screen. They were not what you were looking at. And the more surfaces a system +grows, the more this costs, because each new surface is a fresh chance to +forget a rule that lives somewhere it has no reason to look. + +## What to do instead + +**Find the narrowest waist.** Somewhere there is a function every caller +passes through — a `readPage`, a repository method, a serialiser. That is where +a rule about *what data is allowed to leave* belongs. If there is no such +point, that is the actual finding, and creating one is the fix. + +**Move the rule down, then delete it from above.** Leaving the renderer check +in place "for safety" is how you end up not knowing which layer is load-bearing. +Two enforcement points that can disagree are worse than one, because the next +person will fix the wrong one. Delete the upper check and let the test prove +the lower one works. + +**Watch for the fast paths.** This is the part I got wrong even after moving the +rule down. Once the invariant lived in `readPage`, I still had an index — a +cache that answered listing and counting queries without reading pages at all. +It happily counted, listed and searched hidden pages, because it never called +the function that now held the rule. **Any optimisation that bypasses the choke +point bypasses the invariant.** Enumerate the fast paths deliberately; they are +exactly the code written to avoid the thing you just made authoritative. + +**Test the rule, not the surface.** The test that finally held is shaped like +this, and it is worth the ugliness: + +> For each of the endpoints that can return page data — and here is the list, +> hard-coded — fetch this hidden page and assert it is not present. + +Hard-coding the list looks like bad style. It is the point. When someone adds a +surface, the list is stale, and the fix is to notice that the new endpoint is +not in a test whose name is *every surface*. A loop over a dynamic route table +would have quietly kept passing. + +## The general form + +> Enforce a rule at the layer that owns the data, not at the layer where you +> noticed it was missing. + +The two are rarely the same layer, and the distance between them is exactly the +number of ways your rule can be wrong. + +See also [[hindsight/gaps-between-components]], which is the same lesson +approached from the test suite instead of the architecture, and +[[hindsight/pseudonyms]], which is about the half of the masking job that even +a correctly-placed read-time rule does not do. +

Revisions

12h ago · 2026-09-05 02:40
node · from localhost · via api
mtnrzvc · 106 lines · 4991 bytes · commit: create · diff