# A tenant facade that fails closed

A customer session never calls an operator path directly. `tenantAdapt` rewrites the path it recognises and turns everything else into a blocked request that answers 403, and `customer_safe` in the middleware refuses any `/api/*` the session is not permitted to call. Both fail closed, so a new capability is unreachable for a tenant until somebody makes it reachable on purpose.

- **Status:** Available
- **Audience:** both, developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/research/closed-by-default-facade/

## Two refusals, and why neither guesses

The alternative design is a permissive rewrite: strip a prefix, add another, pass anything through. It is one line shorter and it fails in the worst available direction — a route added for the operator becomes immediately reachable by a tenant, with operator semantics, and nobody finds out until the semantics matter.

| Gate | Where it runs | What it does with something unrecognised |
|---|---|---|
| `tenantAdapt` | `webapp/src/services/tenant-adapt.js`, in the browser | Rewrites it to a blocked path, which answers 403 — it does not attempt the operator path |
| `customer_safe` | `backend/app/main.py` middleware | Refuses any `/api/*` a customer session is not on the allowlist for, whatever asked |

The browser half is a convenience: it keeps a customer's screens from issuing requests that were never going to succeed, and it makes the failure legible. The server half is the boundary. Neither depends on the other being correct.

## The chore this creates is the design working

Every new operator path needs either a rewrite in `tenantAdapt` or a written reason for not having one. That is a real recurring cost, paid per feature, and it buys the property that no capability leaks to the other audience by default.

It also produces a characteristic symptom that is worth recognising on sight: a feature that works perfectly for the operator and returns 403 for a customer. That is almost never the feature. It is the rewrite that was not added, or the allowlist entry that was not added, and both are a two-minute fix once the shape is recognised.

> **Note** The customer surface also renames things, which is a second reason a path can be unmapped rather than forbidden. A customer's `people` are the operator's `relationships`; its `knowledge-sources` are the `knowledge-bank`. Those renames are recorded as aliases rather than left for a reader to infer.

## Enumerated rather than asserted

A rule like "every capability is available to both audiences" is worth nothing as a sentence in a document. `tools/test_audience_parity_v1.py` reads the flattened route table out of the running application and compares every operator capability against every `/api/customer/**` one.

- `ALIASES` records where the customer surface renames something, so a rename is not mistaken for a gap.
- `KNOWN_GAPS` lists the capabilities a tenant still cannot reach, each with its reason. The list may shrink and must never grow.
- The suite fails on a new one-audience capability, which catches the forgotten rewrite at the moment it is introduced.
- It also fails on a gap that has been closed and not removed from the list, which is what stops the exceptions file becoming a graveyard nobody reads.

Three capabilities are correctly reachable only by the operator, and they are exceptions of a different kind: verifying customer payments, JBRH's own pricing, and website enquiries addressed to JBRH. Those are the platform being run rather than Connect being used, and `connect_inquiries` sits outside every workspace by design.

## Reading a 403

1. Establish which gate refused. A blocked path in the browser's network panel points at the rewrite; a refusal on the real path points at the allowlist.
   - Result: The two have different fixes and there is no point guessing between them.
2. Check whether the capability is in `KNOWN_GAPS`.
   - Result: If it is, the refusal is expected and the entry says why. If the reason no longer holds, closing the gap is the change — and the suite will then require the entry to be removed.
3. Check whether it is one of the operator-only three.
   - Result: Payments, JBRH pricing and website enquiries are refused on purpose and are not a parity failure.
4. Otherwise, treat it as a missing rewrite or a missing allowlist entry and add the one that is missing.
   - Result: The parity suite goes green, and the capability is reachable by both audiences over the same implementation rather than a second one.

## Questions

### Why does something work for the operator and refuse for a customer?

Because the customer path is explicit. Either `tenantAdapt` has no rewrite for it, so the request becomes a blocked path, or `customer_safe` does not list it. Both are omissions in the mapping rather than faults in the feature, and the parity suite exists to catch them before a customer does.

### Is a 403 for a customer always a bug?

No. Three capabilities are deliberately operator-only, and `KNOWN_GAPS` records the remaining ones with reasons. What would be a bug is a gap nobody wrote down — which is precisely what the enumerated comparison refuses to let through.

### Could a customer bypass the rewrite by calling the operator path directly?

The rewrite is in a browser and is not what stops them. The allowlist in the middleware refuses the request whatever issued it, and beneath that the workspace kernel and the database's own policy still filter every row.

## Related

- [One implementation, two audiences](https://connectbyjbrh.com/research/two-audiences-one-implementation/)
- [Three independent layers of tenant isolation](https://connectbyjbrh.com/research/three-layers-of-isolation/)
- [Security and isolation](https://connectbyjbrh.com/docs/security/)
- [Defects that are invisible in the source and obvious in a browser](https://connectbyjbrh.com/research/invisible-ui-defects/)
- [Account and access](https://connectbyjbrh.com/docs/account/)
- [Five ways a test suite has passed while proving nothing](https://connectbyjbrh.com/research/false-passing-tests/)

## What this page is based on

- `docs-source/sources/GENERAL.md` §1 and §2 — the two audiences, `tenantAdapt`, `customer_safe` and the parity suite
- `docs-source/sources/CHANNELS.md` §5 and §6 — the customer surface's renames and the operator-only capabilities
- Connect capability registry (docs-source/facts.py) — `owner_tenant_routing`, `payment_verification`, `website_enquiries`
