# 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.
