All cases

2026-06 · Workshop

A public window and a private control plane over a live house, with no inbound ports

Home Assistant on the LAN, a bridge that only dials out, authorization enforced inside Postgres, and a public page that cannot leak whether anyone is home. Phases 0 through 4 merged in one day. The security model is a button, not a paragraph.

System designVerification and trustShipping under constraint

9

merges on the surface

17

checks run against the live database

413 to 639 ms

command round trip, 3 s budget

Problem

I wanted to control my house from anywhere and I wanted a public page that shows the house is real and running. Those two goals normally fight: expose the automation server and it is a scannable target; put authorization in application code and one bug in a route handler is control of physical devices; publish live telemetry and anyone can learn when the house is empty; queue a command while the link is down and it fires hours later on a garage door.

Constraint

Consumer hardware and a home network I refuse to expose: no port forward, no VPN endpoint, no tunnel listener. A public page had to be truthful without becoming a schedule of when nobody is home. Leak sensors had to open an incident with no human awake to notice.

Decision

Three planes with one-way trust. A small worker on the LAN (under 300 lines, two dependencies) opens an outbound socket to Home Assistant and an outbound connection to a hosted Postgres broker, and accepts nothing. The web app never talks to devices; it inserts a command row through one SECURITY DEFINER function that re-checks membership, role, device flags, capability allowlist, and a rate limit inside the database. Commands expire at sixty seconds instead of firing late. New devices land locked down. Public telemetry flows through two views that coarsen state to a scalar, delay it per device, and exclude presence, locks, garage, alarm, cameras, and media unconditionally.

Rejected

  • Port-forward or tunnel into Home Assistant

    Every one of those is an inbound listener on the house. Nothing connects in, so a compromised web tier still has nothing to reach.

  • Authorization in the route handlers

    One bug in a handler becomes control of physical devices. The insert path is the check, so the app cannot skip it.

  • A live public badge

    Live telemetry is an occupancy feed. State is coarsened, delayed, and stamped with its own age, and the page renders observed fourteen minutes ago instead of live.

What shipped

Schema, bridge, admin heartbeat, state mirror with staging promotion, live controls with expiry, scenes through the same command pipeline, an incidents engine with leak-to-critical-incident in about a second and a scheduled bridge-offline check, and the public observability page, all merged on 2026-06-12. A runnable systemd unit for the bridge followed in September.

What I'd do differently

The first heartbeat could report the bridge healthier than it was. The same-day fix (#1042) made the heartbeat truthful and added the in-app verification panel, which is the part I should have built first: a trust boundary you cannot re-prove on demand is a claim, not a control. The bridge also ran without a proper service unit for three months before #2346.

If you ask me about this

Why does the sensitive side only dial out?
Because the attack surface of a listener is the listener. The bridge opens outbound connections to Home Assistant on the LAN and to the broker, and accepts nothing. If the web tier is fully compromised there is still nothing on the house to connect to.
Why put authorization in the database instead of the app?
The database is the write path. A command row can only be inserted through one function that re-checks membership, role, device flags, capability allowlist, and a rate limit inside Postgres. A buggy route handler cannot bypass a check it never gets to run.
How do you know the public page cannot leak occupancy?
The verification panel proves it against production on a click: presence, locks, garage, alarm, cameras, and media are excluded unconditionally, a lock flagged public with hour-old state still never appears anonymously, and every run is written to the audit tail.

Evidence

  1. feat(manor): phase 0 foundation, schema plus bridge skeleton plus admin heartbeat

    #1033 · 2026-06-12 · feature

  2. feat(manor): phase 1 state mirror, staging promotion, live board; retire sitter page

    #1040 · 2026-06-12 · feature

  3. feat(manor): in-app gate verification panel + truthful-heartbeat and resync-provenance fixes

    #1042 · 2026-06-12 · bugfix

  4. feat(manor): phase 2 command loop, live controls, expiry honesty, verifier ladder

    #1051 · 2026-06-12 · feature

  5. feat(manor): phase 3a scenes through the command pipeline, atomic and role-gated

    #1055 · 2026-06-12 · infra

  6. feat(manor): phase 3b incidents engine + phase 4 public observability page

    #1059 · 2026-06-12 · feature

  7. MANOR bridge: runnable systemd unit + install.sh

    #2346 · 2026-09-10 · feature

Receipts

Checked at build against the published corpus.

  • Every cited PR exists in the published corpus7 / 7
  • Pending PRs are named as pending, never passed off as mergedall merged
  • Cited PRs sit in the case's disciplinesWorkshop
  • Numbers with no source say so0 unmetered

www.jakelawrence.xyz/research/case-study-library/manor-control-plane

Cite: Lawrence, J. (2026). A public window and a private control plane over a live house, with no inbound ports. jakelawrence.xyz case study library.

Need something like this built?

I design and ship AI tools, full-stack apps, and data pipelines — end to end, to production. Tell me the problem in a sentence; I'll give you an honest read on fit within a day.

Work with me →