The owner rule — the owner decides who may join, and at what tier
Status: built · opt-in · behavior-neutral — not yet enabled in production. The full path below is implemented and tested end-to-end for both trust-root modes: on-prem, where the owner pins a single
OWNER_POLICY_KEY, and hosted multi-tenant, where a tenant registry (OWNER_REGISTRY=1) resolves the trusted key by the room's app so each governed room is rooted in whichever registered tenant owns it (a policy for an unregistered app, or one signed by the wrong key, is refused). Also built: the create-time mandate, the render's integrity gate, tier-driven capabilities, client peer verification, and an operator CLI (tools/owner-policy.mjs) to mint keys, sign policies, and onboard tenants. It stays inert until an owner opts in — keys unset, rooms behave exactly as before, the AWS build is unchanged. Also built (hosted): a tenant-key onboarding endpoint —op:'register-owner', gated by a platform-signed grant scoped to the app + owner-key id, create-once — so a tenant self-registers its owner key instead of the operator running the CLI. And key rotation (op:'rotate-owner', authorized by the current key) — the render verifies each room against the specific key its marker names, so rotating doesn't break existing rooms, and arevokecuts a compromised key. The owner rule is now complete end-to-end. This is how an app owner gets authority over admission without breaking the content-blind model.
The gap
A Witbitz room is a door whose key is the link. Whoever creates the room picks the lock, and the choice is sealed — even the platform can't read it. Great for a private room between equals; a problem the moment there's an owner. If a company runs an app on Witbitz, it has no way to say "every room in my app is only for my people, and gold customers get more than free ones." A creator can leave the door open, and the owner can neither see nor override it. Admission is the creator's private choice; there is no owner authority above it.
This is the design that adds one — turning "the link is the authority" into "the owner is the authority," opt-in, without giving up content-blindness.
The rule: a signed App Policy
The owner writes one small document and signs it with a key only they hold:
AppPolicy {
app: "acme-portal" // which app this governs
ownerKey: <owner's public key> // the root of authority
version: 3 // for rotation
tiers: [
{ name:"open", admit: anonymous, caps:{ read }, limits: low }
{ name:"member", admit: { issuer:"acme.okta.com", any:true }, caps:{ read, chat }, limits: mid }
{ name:"gold", admit: { issuer:"acme.okta.com", claim:{group:"gold"} },caps:{ read, chat, act, premium }, limits: high }
]
roomTiers: { default:"member", admits:["open","member","gold"] } // what a room may be created as
notBefore, notAfter
}
→ signed by ownerKey
The signature is the whole trick: nobody without ownerKey can produce a valid policy, so nobody can forge a weaker
one. Admission stops being binary — it's a ladder. open is anonymous with read-only caps (a freemium/preview
tier); gold is a verified user carrying a specific entitlement claim, with full capabilities. One signed table spans
anon-public to premium-verified, and the flat "must be my user" rule is just its one-tier case.
Where the owner's authority actually lives
Two anchors, because two different parties must verify the policy:
- The room-creation credential — hosted, the owner's tenant key; on-prem, the owner's own create endpoint. This is what makes a room belong to the owner: an owner-room can only be minted through the owner's credential.
- A pinned copy of
ownerKey— baked into the owner's app and published at a well-known URL — so the render and other members can independently check that a room's policy was really signed by this owner.
Bound to a room at birth
When a room is created through the owner's credential, create stamps two things onto it:
- The signed policy, sealed in the room config — so the render can read and enforce it with the room key.
- A cleartext marker on the public record —
{ app, ownerKeyId, policyHash }, generalizing today'sgatedflag — so the content-blind platform and the keyless read paths know "this room is owner-gated" without reading anything.
And the owner's create path refuses to mint a room that doesn't carry the policy. The room is born gated; there is no "open" escape hatch under the owner's app.
Only the app can mint its rooms (room-genesis)
There is a subtlety the policy alone doesn't cover: the signed policy is a reusable, public artifact — it's the same document on every one of the app's rooms, so anyone could copy it off one room and stamp it on a room they create. Verifying the policy proves "governed by app X's rule," not "created under X's authority." Left there, an attacker could stand up a look-alike room with X's exact governance and sign-in and phish X's users.
So stamping a policy at creation also requires a room-auth — a signature by X's owner key over this specific room. Only the app owner (its backend) holds the key, so only the app can mint a room stamped with its app; the room id is in the signed core, so a room-auth for one room can't authorize another, and it's bound to the resolved owner key (the single key on-prem, or the tenant's registered key hosted), so it's app-specific. Create refuses a stamped policy with no valid room-auth. This is what turns "governed by X's rule" into "a genuine room of X's."
How a member's tier is decided — and why they can't self-promote
Tier comes only from a signed source, never the client's word:
- IdP claims — the owner's login token already carries groups/roles. The policy maps claim → tier, so the owner's directory is the source of truth for who is gold. Sign in, and the token's claim picks the tier.
- A signed grant — a room admin can promote a specific member for a specific room (the existing signed-capability grant, see Delegated authority), overriding upward.
- The room's tier — a room is created as a tier; a member's effective tier is their entitlement, bounded by what the room admits (the room is the ceiling).
A tampered client can't upgrade itself, because it can't forge the IdP claim or the grant — and the render, not the client, resolves the tier and enforces it.
How it's enforced — four layers, no single point to bypass
| When | Check | Where it runs |
|---|---|---|
| Birth | create refuses any owner-room without the signed policy + marker | the owner's create path (tenant-keyed / on-prem) |
| Writes (every turn) | the render opens the sealed policy, verifies its signature against the pinned ownerKey, resolves the member's tier, and gates each action with the capability gate. Policy absent or signature invalid → refuse. |
the certified render (already decrypts per turn to check admission) |
| Blind reads | the cleartext marker makes keyless reads refuse; keyed reads pass the same identity gate | the content-blind platform |
| Join | the member's app reads the room policy and verifies it against its own pinned ownerKey — and won't join a room whose policy is missing or unrecognized |
every member's app (peer check) |
The render already does the per-turn identity gate and the capability gate; the new move is that the policy now comes from the owner-signed artifact and the render refuses to run if it doesn't verify — instead of trusting the creator's choice. The boolean tier caps drop straight into the existing gate; the one genuinely new enforcement is per-tier limits (rate / token / tool budget), and the platform already meters sessions and tools — so a gold tier is just a higher metered ceiling. Which means the ladder doubles as the owner's pricing surface: admission, capability, and monetization collapse into one signed table.
Why nobody can strip or weaken it
- Forge a weaker rule? No — it must be signed by
ownerKey. - Omit it at creation? No — the owner's create path won't mint the room, and the render refuses a room with no valid policy.
- Delete the policy or marker later? The render refuses a room whose policy doesn't verify; the marker guards the blind paths; peers reject unrecognized rooms.
- Impersonate the app by stamping its public policy on your own room? No — creation requires a room-auth signed by the app's owner key over that specific room (room-genesis, above), so only the app can mint a room stamped with its app.
- The only escape is creating a room outside the owner's app — which isn't "a room in the owner's app," and the owner's users' apps won't join it. So "every room in my app requires my login" holds exactly.
Registration — Witbitz federates; it doesn't run the directory
A "registered user" is a record in the owner's identity provider, not a Witbitz account. Three distinct steps, and only one is a fresh signup:
- Owner registration (once, the trust root). The owner registers the app: its
ownerKey, its IdP (issuer + JWKS, theSPACE_OIDC_ISSUERSentry), and its claim → tier map. Hosted, this is tenant onboarding proven with the tenant key; on-prem, it's config. After it, the render and peers know "tenant Acme's authoritative ownerKey is X." - User registration (per user, in the owner's directory). However the owner's IdP already does it — enterprise SSO / SCIM provisioning, self-signup in the owner's flow, or admin invite. The tier is a group the owner assigns, so a user can't self-register as gold. If the owner has no directory, they can use a managed auth provider or Witbitz's email-code issuer as the directory — but the owner still owns the "who is gold" data.
- Device enrollment (per device). On first use, the app mints a signing key and binds it to the identity at sign-in. One identity, many devices, each enrolled independently; losing a device just re-enrolls a new key against the same registration.
So joining a room is authentication, not registration — the room federates a sign-in against a registration that already exists and reads the tier from the signed token:
register once (owner's directory) → owner assigns a tier (a group) → open a room link
→ app forces sign-in via the owner's IdP → token {identity + tier claim} bound to the device key
→ render admits at that tier
Revocation is symmetric: remove the user from the directory (or the gold group) and their next sign-in fails or
drops a tier — enforced on the very next turn.
Orthogonal to attestation
This is the identity + capability axis. Whether the client is trustworthy code is a separate axis —
the attested client. They compose: a tier's admit can
additionally require an attested client (e.g. "gold requires the attested app"), but identity-tiers work on the plain
web with no attestation in sight. Keep them separate — one is about the person, the other about the code.
The honest trade
Requiring registration deliberately reintroduces a central authority — the owner's directory — into a model that is otherwise account-free and link-based. That's not a regression; it's exactly the control an owner asks for, and it is opt-in per app/room: governed, tiered rooms federate to a directory; open link-rooms don't. The owner chooses where on the spectrum each room sits. The platform stays content-blind throughout — the policy is enforced in the certified render and by peers, never by a platform that reads the room.
What's reused, and what's new
- Reused: the sealed
admission/allow/revokedconfig + the render's per-turn gate, thegatedcleartext marker, pluggable OIDC issuers, tenant keys, the signed-grant capability gate, and session/tool metering. - New: (1) the signed
AppPolicyartifact + a pinnedownerKey; (2) the create-time mandate through the owner credential; (3) the render's signature check + refuse-if-invalid; (4) peer verification in the app; (5) the tier table + claim → tier resolution + per-tier limits wired to metering.
One line: the owner's signed policy replaces the creator's free choice — stamped on every room at birth through a credential only the owner holds, resolving each federated sign-in to a tier, and enforced in three independent places (render, blind paths, peers) — so a room's authority moves from "whoever holds the link" to "whoever holds the owner key."
Machine-readable source: the-owner-rule.md · every doc in one fetch: llms-full.txt · ← room-link-auth · async-spaces →