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.