Essay

The Same Shape, at Home

5 min read ·

For ten years I argued that writing a policy and enforcing it are two different jobs. Then I ran into the same problem in my own house.

At work that was a choice. At home it isn't.

Every place my family's screen rules actually get enforced belongs to somebody else. Apple owns Screen Time and won't let one app read another's restrictions. Family Link has no public API. Roblox, YouTube and Xbox are account settings and nothing more. I couldn't build the enforcement layer here if I wanted to, and nobody is going to hand me one.

So the shape I spent years defending at work is the only shape available at home. You get the authoring layer, or you get nothing.

The problem nobody names

Open any of these apps and the settings page is honest. Roblox tells you the truth about Roblox. Screen Time tells you the truth about the iPad. Each one shows you a green tick for its own slice.

The problem is what a green tick looks like. It looks like protected. It means protected here. It says nothing at all about the other four routes to the same thing. "No screens after 9:30" is one sentence to a parent and five settings pages to a computer, and nothing anywhere shows you all five at once.

Then there's drift. An OS update resets a toggle. A kid uses the browser instead of the app. Someone changes a setting to get through one evening and forgets to change it back. You set this up in June. It's September. You have no idea what's still true.

Nothing here lies to you. It just never shows you the whole board, and never tells you when a square has changed.

Define once

So I built the small version of the thing I refused to build big. It's called Policy Plane.

You write the rule once, in plain English: Alex may use Roblox for at most 90 minutes a day. The sentence on screen is generated from the parameters, so it can't drift from the thing being checked.

It works out which apps can carry that rule and which can't. If an app can't, that's a fact about the vendor, not a gap you should feel bad about.

It turns the rule into each vendor's own steps. One sentence becomes Screen Time's Downtime, Roblox's Screen Time and Xbox's Screen time — three different procedures from one thing you wrote.

And where it can, it reads the setting back. A browser extension opens the settings page in a tab you can watch, using the session you're already signed into, and reports what the page said.

One rule routed to every surface that can carry it A single rule — Alex may use Roblox for at most ninety minutes a day — fans out to six surfaces. Screen Time, Roblox and Xbox can carry it; only Roblox can be read back automatically. NextDNS, Family Link and YouTube are out of scope, each for a stated reason. Alex may use Roblox for at most 90 minutes a day written once Screen Time can carry it a human confirms Roblox can carry it read back automatically Xbox can carry it a human confirms NextDNS out of scope — no time rules at all Family Link doesn't cover Alex YouTube doesn't govern Roblox A dashed box is not a gap you should feel bad about. It is a fact about the vendor, and the page says which one.
One sentence, six surfaces, three different answers about whether it can even be expressed.

The extension reads. The plane judges.

That last part is the piece I'd defend hardest.

The extension returns the text on the page — the literal string "1 Hour". It never returns a verdict. Something separate re-reads that string and decides what it means.

Anything that both reads a setting and rules on whether it's correct is marking its own homework. And it fails quietly, because a reader that misreads usually misreads in the direction that looks like success. Split the two, and the worst thing that can go wrong is a bad string — which comes out as unknown, not as a confident lie.

Two more constraints fell out of the same instinct. The engine can only waitFor, click and read. There is no verb for writing, so a recipe cannot change a setting — there's no way to say it. And the page sends the extension a recipe id and parameters, never a URL or a selector, so a page that goes wrong can't aim a logged-in browser somewhere it picked.

The trust boundary between the plane and the extension The dashboard sends only a recipe identifier and parameters to the extension. The extension reads the vendor page using only wait, click and read, and returns only the raw text it saw, never a verdict. A separate judge decides what the text means. THE PLANE dashboard the judge THE EXTENSION service worker waitFor · click · read VENDOR PAGE your logged-in session recipe id + params never selectors, steps or URLs read only raw text readings, and a result code never a verdict Two crossings, each narrow on purpose.
The extension knows what a recipe means. The page only knows what to ask for.

Five verdicts, and the two that earn their place

A check comes back as satisfied, stricter, not satisfied, unexpressible or unknown. Only the first two count as in force.

Unexpressible means the vendor doesn't offer that value. One of the vendors I test against offers 0, 30, 60 and 120 minutes and nothing in between. You can't set 90 there — not because you forgot, but because the option doesn't exist. "You never set it" and "it can't be set" look identical in an empty cell and need opposite responses. One sends you to the vendor. The other sends you to rewrite your rule.

Unknown is the one people get wrong. If the page didn't load, or asked for a login, or turned out to be showing a different child, the answer is unknown — never not satisfied. "We couldn't look" and "it isn't set" are different facts. Conflate them and you will eventually tell a parent their child is unprotected because the wifi dropped. After the second false alarm nobody reads it again.

Three vendors, one rule, read in sequence. Roblox is a real account; A and B are simulated. 38 seconds, no audio.

The rule is ninety minutes a day. Roblox reports one hour forty-five — a real limit, set, and looser than the rule, so it reads Not in force. The first simulated vendor reports one hour, which is stricter than asked; that still counts as In force, because a rule is a ceiling. The second offers only 0, 30, 60 and 120 minutes. Ninety is not on its list. Nothing is broken and nothing is unreachable — the rule simply cannot be written there, which is why Can't be expressed is a verdict and not an error.

The bug that would have shipped green

Here's the one that still bothers me.

The rule was 90 minutes a day. The vendor's dropdown said No limit. My parser read that string, found no digits, and returned zero. Zero is less than 90. Less than 90 is stricter than you asked. Stricter counts as in force.

A child with completely unrestricted access would have shown up as a green tick, and every step of the reasoning would have been consistent.

That's the failure I now think is the real one in this kind of work. Not the error that crashes — the error that returns a plausible value and passes every check after it. It fails towards reassurance, and nobody investigates a green tick.

The fix is a sentinel: "No limit", "None", "unlimited" and "off" all parse to infinity, which is never less than anything. What actually found it was setting the real thing to No limit and watching the page confidently tell me everything was fine.

Guards that can't fail aren't guards

I wrote a lot of tests. Then I started breaking the code on purpose to see which ones noticed. Six didn't. One judge turned out to have no tests at all — I deleted two of its branches and nothing changed.

The best version of this was the last one. Before publishing I wrote a leak checker: scan a fresh clone, every commit on every ref, for anything private. Its self-test plants a secret and asserts it gets caught, so the checker can prove it is capable of failing.

I ran it against the repo I was about to publish. It found a real child's account id — in the leak checker's own source, where I'd used the real value as the example secret to plant.

The gate caught its own author, in the file whose entire purpose was to prevent that exact mistake.

What it refuses to do

It doesn't watch anyone. No usage, no activity, no messages, no location. The only things it records are settings, and confirmations you made yourself.

That isn't a promise in a README. A test whitelists every field allowed to reach the page, and a new one fails the build until somebody justifies it in writing. When I added the vendor's own words to the display — so the page shows "1 Hour" next to its verdict instead of asking to be believed — that guard stopped the build and made me argue, on the record, that a control's value is not a record of use.

Governing a policy and monitoring a child are close enough that the second arrives by accident if nothing structural stops it.

The part that transferred

Almost none of this was new thinking. Separate authoring from enforcement. Stay out of the runtime path. Translate one intent into each system's own vocabulary. Be incomplete on purpose, because that's what makes you safe to sit next to.

I argued that for a decade across dozens of enterprise systems, and the same shape holds for one family and six consumer apps. The scale changed by four orders of magnitude and the architecture didn't move.

The only difference is who owns the enforcement points. At work I chose not to own them. At home the choice was made for me — and the discipline you pick up voluntarily turns out to be the one that saves you when it stops being optional.

The code is public, with simulated vendors so you can watch the whole loop run in two minutes without an account anywhere.

Filed underPolicy · Architecture · Building
RelatedThe Layer I Refused to Build · Which of Your Agents Is Actually Ready?