97 KiB
hip, title, author, type, category, status, implementation-go, created, requires
| hip | title | author | type | category | status | implementation-go | created | requires |
|---|---|---|---|---|---|---|---|---|
| 0527 | Tenancy | Hanzo AI Team | Standards Track | Security | Draft | partial | 2026-09-29 | HIP-0026, HIP-0118, HIP-0519 |
HIP-0527: Tenancy
Abstract
Four questions decide every request: is this person a SuperAdmin, may they act in this org, which org are they acting in, and who pays. Today IAM, cloud, ai, commerce, the gateway and seven sites each answer some of them, from different inputs, and disagree. This HIP defines each answer once, as a total function of an append-only log of facts IAM holds — founded, invited, accepted, assigned, revoked, switched, assumed — computes all four in IAM, signs them into the token, and makes every other component a reader.
Four things that were braided come apart. The directory id is where a person's
login lives and grants nothing. The admin org is where SuperAdmins live and
nothing else. An org is a tenant, and access to one comes only from founding it or
accepting an invitation to it. Every person has an org of one that is their
wallet; hanzo is Hanzo Inc's own org, joined by membership like any other.
implementation-go: partial: IAM already decides SuperAdmin on the account row,
records assume and invitation acceptance on its platform-written trail, and mints
the orgs, billing_account and type claims. §4 lists what goes, §5 is the
migration, §8 is what an implementation must satisfy before it merges, and §9
extends the same facts to agencies, white-label brands and apps built on Hanzo.
Motivation
Measured across the code that serves production today (§4 names every site):
- SuperAdmin is tested four ways: the account's own org (IAM), the first entry
of
orgs(cloud, authz),adminanywhere inorgs(the JS SDK, gateway's authz pin), and theownerclaim — which names the application's org, not the person's (console, commerce, the gateway guards, ai's fallback). Two sites count every member of eight "paid" orgs as a SuperAdmin. - Membership is three different sets inside IAM alone — the token's, the principal's and the memberships list — and sites read whichever they found. Access is granted three ways: founding, accepting, and a direct write any org admin (or a plane op, or hanzo.team's sign-in) can make. Of 421 live membership rows none carries an invitation.
- The acting org is a client header that five components resolve against the signed set with different fallbacks, and three of them let a SuperAdmin enter any org by header, off IAM's audited assume.
- The payer is resolved in more than a dozen places. The rule that decides it
turns on the org's name: in
hanzoa member pays from a personal wallet inside Hanzo Inc's own ledger, anywhere else from the org's pool. Customers who signed up through Hanzo's application act inside Hanzo Inc's workspace. - Where a login lives is the org it founded: onboarding moves the account and re-keys it, stranding tokens, memberships and keys filed under the old address.
Every one of these is the same defect: a question with more than one answer.
Specification
1. Formal core
Types.
Account an identity that signs in, keyed by `sub` (opaque, immutable)
Org a tenant, keyed by its slug (immutable)
Dir = {id, admin} where an account lives
Reserved= {admin, id, org, app, iam} namespaces no one is a member of
Role = owner > admin > member
kind : Account → {person, program}
owner : Account → Dir set at creation, never changed (I12)
personal: Org → Bool set at creation
ns : Record → Reserved ∪ Org the namespace a record is filed under
id is the directory: every account that is not a SuperAdmin — every person,
whichever way they signed in, and every program (service account) — lives there,
and living there confers nothing. admin holds the SuperAdmin accounts and
nothing else — no org, application, key, token, certificate or audit row — so an
id that begins admin/ names a SuperAdmin and nothing else can. An org with its
own identity provider is still an ordinary org: it admits people through that
provider, it does not own them.
Every record has exactly one namespace:
ns(x) = admin x is a SuperAdmin account
= id x is any other account, person or program
= o x belongs to org o: its memberships and roles, invitations,
projects, workspaces, teams, keys, its brands, its own
applications (a brand's `<o>-<app>` included) and identity
providers
= org x is an organization record (id org/<slug>)
= app x is an application holding a platform capability
(id app/<name>)
= iam everything else IAM keeps: platform certificates and
providers, sign-in challenges, federation states, audit
rows no org owns, and every per-account record (tokens,
sessions, codes, factors, passkeys, chain wallets),
keyed by the account's `sub`
Facts. IAM holds one append-only log F. Tenancy has no other state: every
membership, role, acting org and payer IAM serves is a fold over F.
founded(a, o, by, t) a holds new org o; by ∈ {a} ∪ SuperAdmins
invited(n, o, m, r, by, t) invitation n into o for mailbox m with role r
accepted(a, n, t) a, proving mailbox m (or o's own IdP), took n
assigned(a, o, r, by, t) role change for an existing member
revoked(a, o, by, t) a's access to o ends; by = a is leaving
assumed(a, o, t) SuperAdmin a enters o for support
released(a, t) a leaves the org it assumed
switched(a, o, t) a picks o as the org it acts in, on every surface
manages(g, c, r, by, t) org g manages org c; g's people act in c at most as r
unmanaged(g, c, by, t) either side ends it
pays(g, c, t), unpaid(g, c, t) g funds c's usage
funds(v, x, t), unfunded(v, x, t) org v funds every call through its application x
branded(b, v, t) brand b exists, owned by org v
hosted(b, h, t) host h proven to belong to brand b
appointed(a, p, by, t) SuperAdmin account a created for person p by `by`
dismissed(a, by, t) SuperAdmin account a disabled
moved(a, from, id, t) the one migration that sets owner(a) := id (§5)
Predicates. Each is a total function of F, computed in IAM and nowhere else.
superadmin(a) ⇔ owner(a) = admin ∧ kind(a) = person ∧ ¬dismissed(a)
role(a, o) = the last fact about (a, o) among founded, accepted, assigned,
revoked, read as: founded ↦ owner, accepted(n) ↦ r(n),
assigned(r) ↦ r, revoked ↦ ⊥; ⊥ when there is none
member(a, o) ⇔ role(a, o) ≠ ⊥
⇔ (founded(a,o) ∨ ∃n. accepted(a,n) ∧ org(n) = o)
∧ no revoked(a,o) after the last of them (a person)
assigned has two uses and one rule: for a person it changes the role of an
existing member; for a program it is the only way in, written by an owner or
admin of o when they create the program. Nothing else writes a role.
role(a, o) is a fact about the pair and nothing else. It is filed in o's
namespace with the rest of o's data; no attribute of the account — no
isAdmin, type, tag or group — enters it, and nothing about one org enters
another. "Org admin" means role(a, o) ∈ {owner, admin} for that one o.
home(a) = ιo. founded(a,o) ∧ personal(o) if kind(a) = person ∧ owner(a) = id
= ιo. member(a, o) if kind(a) = program
= ⊥ if owner(a) = admin
orgs(a) = [home(a)] ⧺ sort{ o | member(a,o), o ≠ home(a) } (⊥ omitted)
acting(s) = assumed(s) if assumed(s) ≠ ⊥
= t = switched(a(s)) if brand(t) ∈ {⊥, brand(app(s))}
∧ (member(a(s), t) ∨ via(s) = g ∧ manages(g, t)
∧ member(a(s), g))
= home(a(s)) otherwise
payer(s) = hanzo if assumed(s) ≠ ⊥
= seller(s) if funds(seller(s), app(s))
= g if pays(g, acting(s))
= acting(s) otherwise
seller(s) = the org app(s) is registered in
brand(h) = ιb. hosted(b, h) a host with no brand serves nothing
brand(o) = the brand o was founded under; ⊥ for a personal org
orgs(a, b) = [home(a)] ⧺ sort{ o | member(a,o), brand(o) = b, o ≠ home(a) }
ledger(o) = the commerce account org:o
platform = Σ o∉Reserved (paid(o), spent(o), owed(o) = paid(o) − spent(o))
s is a token family of account a(s), minted for application app(s).
switched(a) is the account's latest switch and follows the person across every
surface of one brand; via(s) names the managing org a switch went through (§9);
assumed(s) belongs to the one token family that assumed. payer(s) = ⊥ (a SuperAdmin outside assume)
refuses every metered call. hanzo is Hanzo Inc's own org: support work is
Hanzo's cost, never the customer's. platform is a view Hanzo's books compute —
owed is the liability Hanzo carries for customer prepaid — and never an account.
Invariants. Each is checked on every write and nightly on live data (§7).
I1 superadmin(a) ⇔ owner(a) = admin ∧ person ∧ ¬dismissed(a) nothing else confers it
I2 superadmin(a) ⇒ ∀o. role(a, o) = ⊥, so orgs(a) = [] SuperAdmins hold no org role
I3 member(a, o) ⇒ o ∉ Reserved the directory confers nothing
I4 person ∧ owner(a) = id ⇒ ∃! o. founded(a, o) ∧ personal(o) one org of one per person
I5 personal(o) ⇒ |{a | member(a, o)}| = 1 no one is invited into it
I6 o ∉ Reserved ⇒ ∃! f. founded(f, o) every org has one founder:
f an account, or the agency that created a client org an account, or its agency (§9)
I7 o ∉ Reserved ⇒ ∃a. role(a, o) = owner
∨ ∃g. manages(g, o, owner) no org without an owner
I8 owner(a) ∈ {id, admin}; owner(a) = admin ⇒ person no org owns an account
I9 payer(s) = ⊥ ∨ payer(s) is acting(s), its payer by pays, or seller(s) by funds
∨ payer(s) = hanzo ∧ assumed(a(s), acting(s)) ∈ F payer is named by a fact
I10 every ledger account is org:o; a deposit into org:o by b
⇒ member(b, o) ∨ superadmin(b) no customer money in hanzo's ledger
I11 subscription x ⇒ org(x) ∈ Org \ Reserved a plan attaches to a real org
I12 owner(a) is written once accounts never move (except §5)
I13 readers read claims, never predicates one computation site (§2)
I14 ns(x) = admin ⇒ x is an account ∧ superadmin(x) nothing else lives under admin/
I15 role(a, o) is a function of the facts about (a, o) alone org admin is scoped to its org
I16 superadmin(a) reads owner(a), kind(a), dismissed(a) only no role makes a SuperAdmin
I17 no account carries an admin flag isAdmin is not an attribute
I18 owner(a) = admin ⇒ ∃ appointed(a, p, by, t) ∈ F
∧ (superadmin(by) at t ∨ by = genesis ∧ admin = ∅ at t) only an appointment makes one
I19 creating a writes only founded(a, home(a)) (person)
or nothing (program) creation grants nothing
I20 kind(p) = program ⇒ |orgs(p)| ≤ 1 a program acts in one org
I21 manages(g, c, r) ⇒ g ≠ c ∧ ¬personal(c) ∧ g, c ∉ Reserved
∧ (r = owner ⇒ c has no owner account) an agency never outranks its client
I22 member(x, c) and orgs(x, b) read no manages fact manages ⇏ membership
I23 via(s) = g ∧ acting(s) = c ⇒ manages(g, c) ∧ member(a(s), g) one hop; no chain composes
I24 every via entry and exit is a row on c's own trail agency access is audited
I25 pays(g, c) ⇒ manages(g, c); funds(v, x) ⇒ seller of x = v only a manager or a seller funds
I26 a debit at a seller's own price draws cash only; its margin
is payable to the seller; minted credit never pays a
seller and is never paid out; only Hanzo mints credit cash and credit stay apart
I27 payer debit = Hanzo revenue + seller margin, per debit money is conserved
I28 each host has at most one brand; no brand, nothing served no default brand
I29 a page, mail or token under host h names brand(h) only brands never cross-show
I30 a token for app x carries orgs(a, brand(x)); a switch to
another brand's org is refused; sessions stay on one host each brand sees only itself
I15 and I16 are the non-implications, stated as independence: adding any fact
about (a, o) leaves role(a, o′) unchanged for every o′ ≠ o, and leaves
superadmin(a) unchanged. So role(a, o) = admin implies neither
superadmin(a) nor role(a, o′) = admin. assumed is not a role fact: it enters
neither role nor orgs, and it is the only way a SuperAdmin acts in an org.
A person becomes a member in exactly two ways, founding and accepting; a program
in one, being assigned by the admin who creates it. There is no other: no grant,
no backfill, no row a script writes. Sign-in through an org's identity provider
is accepting — the provider binding is a standing invitation the org's owner
wrote, and the provider's assertion is its proof — and SCIM deprovisioning is
revoked.
2. Where each answer lives
IAM computes the four predicates once, at mint, and signs them. Everyone else reads.
| question | IAM computes | carried as | reader asks (hanzoai/authz Claims) |
|---|---|---|---|
| SuperAdmin? | superadmin(a) from the account row |
owner = owner(a), type |
Sudo() = Owner == "admin" && Type != "application" |
| member of o? | member(a, o), role(a, o) from F |
orgs = orgs(a, brand(app)), [{org, role}] |
Role(o), Member(o) = o ∈ orgs; OrgAdmin(o) = Role(o) ∈ {owner, admin} |
| acting org? | acting(s) |
org; assumed when support, via when managed |
Org() |
| payer? | payer(s) |
billing_account = org:<payer> |
Payer() |
-
ownerstates where the account lives —idoradmin. It never names the application's org. -
orgsnever contains a reserved org, an assumed org, or an org reached only throughmanages. -
The edge's
X-User-IsOrgAdminisOrgAdmin(org)for the acting org only. No token and no userinfo answer carries an account-levelisAdmin. -
orgis the only acting org. The edge mintsX-Org-Idfrom it and deletes any client copy (HIP-0519). No surface sends an org selection in a header. -
billing_accountis the only payer.X-Billing-Account-Idis minted from it. -
Revocation takes effect at the next mint. Every first-party application's access-token lifetime is at most one hour, so
revokedbites within one hour everywhere, with no check outside IAM. -
The one check. Every authorization decision anywhere is one of two shapes, and nothing else:
allow(s, platform) = superadmin(a(s)) allow(s, o, need) = role(a(s), o) ⊇ need ∨ via(s) = g ∧ acting(s) = o ∧ manages(g, o, r) ∧ min(role(a(s), g), r) ⊇ need ∨ superadmin(a(s)) ∧ assumed(s) = oA resource declares which shape it takes. No check reads an account's
owner, namespace, admin flag or founder, an org'sfounder, or a list position;superadminis the one function that readsowner. A SuperAdmin holds no role, so outside an assume a SuperAdmin passes no org check (I2). The edge mints an acting org for a SuperAdmin only fromassumed, so a gate written asIsSuperAdmin(c) || IsOrgAdmin(c)(HIP-0519) computes exactly this. -
API keys: a key is minted for one (account, org) pair. IAM re-evaluates
member(a, o)on every resolution (GET /v1/iam/keys/principal) and namesorg:oas payer; a revoked member's key stops resolving at once.
3. One way per flow
Signup. POST /v1/iam/signup creates account a in id and, in the same
transaction, org p with personal(p) and founded(a, p, a). Where the account
signs up — which host, which brand, which application — changes the page, never
the account. With an invitation code it also records accepted(a, n).
Sign-in. An identifier resolves in exactly one directory: admin for the
admin console's application, id for everything else. An email whose domain an
org has claimed signs in through that org's identity provider. The token's org
is home(a) until the person switches.
Create an org. POST /v1/iam/organizations {name, displayName} by a signed-in
person records founded(caller, o, caller) and writes o with personal = false.
founder and isPersonal are never read from a request. A SuperAdmin provisioning
an org for a customer names the customer: founded(customer, o, superadmin), on
the privileged trail. Nobody founds an org into admin, and a SuperAdmin never
founds one for themself (I2).
Invite. POST /v1/iam/invitations {org, email, role} by an owner or admin of
o, with role no higher than the inviter's, records invited(n, o, m, r, by)
and mails a single-use code IAM mints. One invitation names one mailbox and admits
one account. Personal orgs refuse invitations (I5).
Accept. POST /v1/iam/invitations/accept {owner, code, emailCode} by the
signed-in account whose verified mailbox is m records accepted(a, n). The row
and the fact are one write.
Revoke / leave. POST /v1/iam/delete-membership {user, org} by an owner or
admin of o, or by the member themself, records revoked(a, o, by). The last
owner cannot be revoked (I7). A personal org is never revoked; it ends with its
account.
Role. POST /v1/iam/memberships {user, org, role} by an owner or admin
records assigned(a, o, r, by) for an account that is already a member, and is
refused for anyone who is not. It never creates access.
Switch. POST /v1/iam/switch {org} records switched(a, o) and re-mints the
caller's token with org = o and billing_account = org:o when member(a, o),
and answers 403 otherwise. {org: ""} returns to home(a). Every other surface
picks the switch up at its next mint. The switcher lists orgs from the token it
holds.
Support entry / exit. POST /v1/iam/assume {org} by a SuperAdmin re-mints
their token with org = assumed = o and billing_account = org:hanzo, and
records assumed(a, o) on the privileged trail, filed under o so the tenant
reads who was in it. orgs is unchanged. POST /v1/iam/release re-mints with
org = ⊥. Assume is the only way a SuperAdmin reaches an org.
Pay. Every metered request debits billing_account. A top-up credits the
acting org's ledger. There is one ledger kind, org:<slug>; no person ledger, no
signup-org rule. Hanzo's books (/v1/books) derive the platform ledger — paid,
owed, spent per org, and customer prepaid as a liability — from every org's
transactions; it is a view, never an org's wallet.
Enterprise SSO. An org owner binds an identity provider to o (the provider
row is o's) and claims email domains; the binding is a standing invitation with
a role mapping, default member. First sign-in through it creates a in id
with its personal org, like any signup, and records accepted(a, n_p). SCIM
delete records revoked(a, o). The org controls access to itself and how its
domain signs in; it does not own the account.
Appoint a SuperAdmin. grantSuperAdmin(actor, target) — the one operation
that creates an account in admin — requires superadmin(actor), names the
person target (their id account) and records appointed(a, target, actor)
on the privileged trail. It creates a new account; it never moves or promotes
one. revokeSuperAdmin(actor, a) records dismissed(a, actor). The first
SuperAdmin comes from the seed's declared roster, by the same operation with
actor = genesis, only while admin is empty (I18).
Create a program. POST /v1/iam/service-accounts {org, name, role} by an
owner or admin of o creates p in id and records assigned(p, o, r, actor)
with r no higher than the actor's role. A program acts in that one org (I20).
SuperAdmin visibility. The admin console lists every org and every account
(GET /v1/iam/organizations, GET /v1/iam/users), each org's roster
(GET /v1/iam/memberships?org=) and ledger, and the platform ledger. Every such
request is on the SuperAdmin trail. Reading is listing; acting inside an org is
assume, which grants no role — the tenant's roster never shows a SuperAdmin.
4. Delete list
Read at: IAM v1.34.120 (the version cloud embeds); hanzoai/authz v1.10.42 (gateway and commerce
standalone still pin v1.10.29); hanzoai/account v0.3.3; cloud main
e1fd16bfd7; ai v1.833.256; commerce v1.50.133; gateway main 4bac62d2;
@hanzo/iam main c64df2d8; chat 2898607b; id 5755e9c9; team bb72a186;
ai app fdb4d419; ide 52a7e493; console a042daa3; cli 380c43c6.
Tags: [IAM] changes IAM, [edge] changes the identity boundary,
[bug] is a live defect today and should not wait for the rest.
4.1 IAM — the one computation site, made to compute each thing once
| where | what goes | replaced by |
|---|---|---|
internal/oidc/jwt.go:332, :389 |
owner and organization claims = the application's org |
owner = owner(a); organization deleted [IAM] |
internal/oidc/userinfo.go:78, :81, :93 |
userinfo owner = app org; organization; isAdmin |
owner = owner(a); the other two deleted [IAM] |
pkg/store/membership.go:366-384 |
MemberOrgRefs: implicit home ref from the user row, then every raw row |
orgs(a), the fold over F [IAM] |
pkg/store/membership.go:413-418 |
HomeRole: role from User.IsAdmin |
role(a, o) [IAM] |
pkg/schema/user.go:116 |
User.IsAdmin as an authority (org admin by account flag) |
role(a, o); the field deletes after §5 [IAM] |
pkg/store/membership.go:311-330 |
BackfillMemberships (nothing calls it) |
— [IAM] |
pkg/store/membership.go:405-408, internal/memberships/memberships.go:206-208, :233 |
IsHomeOrg and "home org is not revocable": access implied by where the account lives |
home is a founded org; I3 [IAM] |
pkg/store/membership.go:220-271 |
MemberByIdentifier: sign-in at an org's app reaches people homed elsewhere |
sign-in resolves in one directory (§3) [IAM] |
internal/memberships/memberships.go:141-170 |
ensure: POST /v1/iam/memberships creates access for a person |
assigned for existing members only [IAM] |
internal/memberships/memberships.go:131 |
GET ?user= answers raw rows, a second org set |
answers orgs(a) [IAM] |
internal/authz/authz.go:798, :839-854 |
principal's Admin from User.IsAdmin; membershipRoles: raw rows, a third org set |
role, orgs(a) [IAM] |
internal/oidc/provision.go:139-141, :177-189, :266-270 |
first-run gate; moving the caller into the org (re-keys the account); dropping the old row | found, never move (I12) [IAM] |
internal/oidc/onboard.go (whole) |
POST /v1/iam/onboard, the second way to create an org |
POST /v1/iam/organizations [IAM] |
internal/oidc/onboard.go:113 |
POST /v1/iam/admin/provision: a service token moves a named person into an org |
— [IAM] |
internal/bootstrap/bootstrap.go:67, :514 |
POST /v1/iam/admin/users/upsert creates accounts in any org, admin included, and sets isAdmin |
deleted; admin accounts come only from grantSuperAdmin (§8) [IAM] |
internal/oidc/signup.go:328, :378-391, :398-450; internal/oidc/invite.go:31-36 |
signup lands in the application's org; charter/Charter move it; Registers; orgChoiceMode |
signup lands in id and founds home(a) [IAM] |
pkg/schema/application.go:160 |
OrgChoiceMode |
— [IAM] |
internal/oidc/federation.go:471, :592 |
federated accounts land in the application's org | id; an org's own provider adds accepted for that org [IAM] |
internal/organizations/organizations.go:149, :198 |
create/update copy founder and isPersonal from the request body |
server-set from the verified caller [IAM] [bug] |
internal/oidc/masquerade.go:158-160 |
assume appends the org to orgs as admin |
org = assumed; orgs unchanged (I2) [IAM] |
internal/oidc/invite.go:171 |
every accepted invitation grants member |
the invitation's role [IAM] |
pkg/schema/invitation.go:33-46 |
IsRegexp, Quota, UsedCount, Application, Username, Phone, SignupGroup, DefaultCode |
one mailbox, one role, one use [IAM] |
pkg/store/billing.go:37-63 |
BillingAccount: shape rule, personal wallet inside the signup org — and a plain member of any other brand org (a self-signup through lux-app) spends that brand's pool |
billing_account = org:payer(s) [IAM] [bug] |
internal/oidc/token.go:362-374 |
granted: programs acting in orgs other than their own |
a program acts in the org that owns it [IAM] |
pkg/schema/organization.go:111-114 |
OrgBalance, UserBalance, BalanceCredit, BalanceCurrency mirrors |
money lives in commerce [IAM] |
pkg/store/membership.go:29 and org rows filed under admin |
the registry namespace shares the SuperAdmin org's spelling, so an org id prints as admin/<slug> and reads as a SuperAdmin account |
§4.6 [IAM] |
branch orgs (c9a9a3d18) |
Joined + roster rows that confer nothing: one table, two meanings |
not merged; this HIP supersedes it |
4.2 Reader libraries — read, never compute
| where | what goes | replaced by |
|---|---|---|
authz claims.go:185-190 |
Home() = orgs[0]: payer and authority braided with list order |
Org() reads org |
authz claims.go:220-225 |
Machine() = empty orgs; a SuperAdmin with orgs = [] would read as a machine |
Program() reads type only |
authz claims.go:277-279 |
Sudo() via Home() |
Owner == AdminOrg && !Program() |
authz claims.go:285-287, :299-316 |
opens; OrgAdmin via IsAdmin && Home() |
Role(o) from orgs |
authz claims.go:320-343 |
EffectiveOrg, including :329-331: a SuperAdmin enters any org by header, off the audited trail |
Org(); entry is assume only [bug] |
authz claims.go:352-361, :370 |
LedgerOrg; Location through EffectiveOrg |
Payer(); Location through Org() |
authz claims.go:491 |
reserved set lacks id |
add id |
authz edge/edge.go Strip |
captures client X-Org-Id as a selection |
delete it; Render mints from Org() [edge] |
authz v1.10.29 claims.go:210-230, :263-280 (gateway, commerce pin) |
PlatformSudo = an admin membership at any position; any member may select admin |
the one Sudo() [bug] |
account account.go:56, :79, :285-328 |
SignupOrg, Person accounts, the Payer shape rule |
Parse(billing_account) |
account org.go:81-133 |
EffectiveOrg, LedgerOrg (a third copy) |
claims |
account account.go:240 |
IsMachine over User.Type strings |
type claim |
4.3 Cloud — the edge mints from claims; nothing behind it decides
| where | what goes | replaced by |
|---|---|---|
middleware_identity.go:231-238 |
cliOrg: the client's org selection |
— [edge] |
middleware_identity.go:252; auth_identity.go:326-338 |
homeOrg(); a machine JWT's home read from the owner claim |
owner, org claims [edge] |
middleware_identity.go:340-344; auth_identity.go:490-501 |
Acts: a SuperAdmin may act anywhere by header |
org claim [edge] [bug] |
auth_identity.go:475-477 |
sudo = Home() == "admin" |
authz Sudo() [edge] |
apps/principal/principal.go:705-723 |
BillingOrg recomputed |
billing_account |
apps/principal/wallet.go:201-204 |
PayerFrom drops billing_account |
billing_account [bug] |
apps/account/billing_coresident.go:56-95 |
PinBillingSubject through the Payer shape rule |
billing_account |
apps/gateway/gateway.go:143-150 |
a SuperAdmin targets ?org= |
assume |
apps/platform/apps.go:660-690 |
resolveOrg: a SuperAdmin names another org |
assume |
token_validator.go:34-37, :69-72, :118-199 |
VerifiedIdentity.Owner and Home() recomputed beside the boundary |
claims |
metered_ai.go:103, :134, :190, :211, :264-269 |
internal AI calls build a payer from a bare org (the pool; hanzo for signup-org callers) |
billing_account |
apps/agents/conversation.go:207, :395-406 |
agent turns bill X-User-Owner, not the acting org |
billing_account [bug] |
apps/billing/typed.go:45-67; billing.go:357, :428-440; balance.go:46 |
balance and usage read the acting org while a support debit lands elsewhere | billing_account |
apps/team/account.go:519-522, :581-637; account_store.go:293, :319-414, :478-489; backfill.go:30-59 |
hanzo.team adds the owner-claim org to the session as admin, writes IAM owner grants on sign-in, and keeps its own membership model |
token orgs; no writes on sign-in [bug] |
apps/team/invite.go:120-163, :253-270 |
invites are direct POST /v1/iam/memberships grants to people already in the org |
IAM invitations |
apps/iam/members_rpc.go:166-186; roles_rpc.go:53-125 |
the grant plane op (EnsureMembershipIn, unbounded role); standing recomputed from the store |
— ; orgs [bug] |
apps/account/account.go:804-1016; onboarding.go:19-52; iam.go:85-123, :455-462 |
POST /v1/account/orgs: first run moves the person via admin/provision; an additional org gets no member; the personal slug derives from the UUID |
POST /v1/iam/organizations [bug] |
apps/account/embed.go:139 |
acting org = brand org entitles every self-serve signup to the shared CMS, ERP and helpdesk | entitlement from subscriptions [bug] |
reader.go:45-53; apps/commerce/catalog_rpc.go:39; sale_rpc.go:285 |
in-process commerce calls stamped X-User-Owner = admin; plane ops admit Org == admin rather than SuperAdmin |
the plane's stated caller; Super |
apps/plan/plan.go:144, :184 |
entitlements always read tenant hanzo |
the acting org |
cli/auth.go:121-124; cli/cli.go:255-261; cli/gpu.go:1585-1590; internal/iam/exchange.go:127-155 |
stored org and studio_active_org from the owner claim; an unverified Session.Owner |
org claim |
4.4 ai, commerce, gateway — behind the edge, reading headers
| where | what goes | replaced by |
|---|---|---|
ai internal/iam/jwt.go:91-93, :111-118 |
homeOrg overwrites User.Owner from orgs[0], else the owner claim |
edge headers |
ai util/permission.go:83-88, :99-101 |
org admin by IsAdmin, a user type or a tag; SuperAdmin = Owner == "admin" with no machine check |
X-User-IsOrgAdmin, X-User-IsAdmin |
ai controllers/org_resolver.go:152-266; routers/org_resolver.go:38-68 |
two acting-org resolvers that disagree, a billing resolver, and IAM_ORG fallback |
X-Org-Id, X-Billing-Account-Id |
ai controllers/openai_api.go:142-160, :193, :229-233, :297-305; internal/iam/payer.go:30-53; routers/filter_balance.go:538-637, :841-851; controllers/finetune.go:457 |
payer computed five ways, one of them billing hanzo for an agent key |
X-Billing-Account-Id |
ai controllers/zap_native.go:386-412 |
a user field in the body replaces the caller's billing subject: any signed-in caller reads any org's balance |
the caller's own payer [bug] |
ai controllers/zap_application-deploy.go:121; zap_chat-graph-crud.go:920; controllers/base.go:168-173; conf/app.conf:22 |
inline Owner == "admin" then a client owner honoured; raw X-Org-Id; IAM_ORG = "hanzo" |
edge headers; no principal refuses |
commerce auth/iam.go:205-276, :488, :524 |
its own JWT validation (skips an empty iss), SuperAdmin from the owner claim |
edge headers (HIP-0519) [bug] |
commerce middleware/edgeauth.go (whole) |
X-User-Owner = X-Org-Id = the owner claim |
the edge [bug] |
commerce pkg/auth/middleware.go:81-84; middleware/accesstoken.go:105-123, :148-157 |
iam_authenticated = true when X-Org-Id is merely present |
edge headers [bug] |
commerce middleware/iammiddleware/iammiddleware.go:68-71, :252-258 |
X-User-IsAdmin counted as org admin; X-User-Owner == "admin" grants the Admin bit |
the two headers, kept apart |
commerce api/billing/resolve.go:49-59; balance.go:22-99; metering/middleware.go:159-166 |
payer from account.Payer, ?user= from the caller, raw headers |
X-Billing-Account-Id |
commerce api/billing/tier.go:26-35; webhooks.go:142-145; checkout/org_resolver.go:302-311; metering/env.go:61 |
PaidEcosystemOrgs; "hanzo" as the default org |
tier from subscriptions; no default org |
gateway cmd/admin-guard/authz.go:64, :77-104; main.go:299 |
SuperAdmin from the owner claim with case-fold and trim; X-Org-Id = owner |
authz Sudo(), verbatim; org claim |
gateway cmd/admin-api/handlers.go:49; sources.go:172, :273; cmd/waitlist-guard/main.go:229, :285 |
the same, three more times, plus IsAdmin || owner == admin |
authz Sudo() |
gateway middleware/auth.go:55-58; authpolicy.go:261-265; router_kms.go:82-85 |
a present X-Org-Id trusted; a per-user <org>/<sub> billing key; GATEWAY_KMS_ORG = "hanzo" |
edge headers; billing_account; acting org |
4.5 Sites — one switcher, reading the token
| where | what goes | replaced by |
|---|---|---|
@hanzo/iam src/auth.ts:196 |
SuperAdmin = orgs includes admin (breaks at §5 step 9) |
owner == "admin" and not a program |
@hanzo/iam src/react.ts:186-187, :501-530 |
current org in localStorage; switchOrg without a check |
POST /v1/iam/switch; the token's org is current |
@hanzo/iam src/types.ts:114-115; src/react.ts:1510 |
unread isAdmin/isGlobalAdmin; top-up link with no org |
—; top-up names the acting org |
chat src/data/session.tsx:132-149; src/brand.ts:136 |
the account response's owner as the org; brand org as a default org |
token org; brand is presentation |
id pages/Account.tsx:73-76, :97-103, :116; account/Organizations.tsx:7-12 |
org list from GET /v1/iam/memberships?user= with an owner fallback; an unchecked stored org |
token orgs, org; POST /v1/iam/switch |
id pkgs/onboarding/src/service/onboarding.ts:109-130, :200; pages/Account.tsx:76 |
POST /v1/iam/onboard; role from isAdmin |
POST /v1/iam/organizations; role from orgs |
id apps/account/src/worker.js:823; pkgs/shared/src/org.ts:55; account/Apps.tsx:17; pages/Portal.tsx:102, :142 |
owner || 'hanzo'; billing for the brand org |
acting org |
id pages/Signup.tsx:12-19 |
/signup ignores invite |
reads it, or links go to /join [bug] |
team hooks/useTiers.ts:48-57, :103-104 (same in ai app) |
"superAdmin" = any member of eight paid orgs | Sudo; tier from subscriptions [bug] |
team lib/auth/session.ts:172-212, :233-240 (ai app :365-464) |
a second orgs decoder, X-Org-Id scope, pick() |
token org; POST /v1/iam/switch |
team lib/hanzo/team.ts:66-78; Contacts.tsx:586-587; Orgs.tsx:423-424 |
browser-made invite code; /signup?invite= links hanzo.id drops |
IAM-minted code; /join [bug] |
team Orgs.tsx:369-373 (ai app :352-356); Account.tsx:307 |
POST /v1/account/orgs, a third way to create an org; a link to /orgs/new, which does not exist |
POST /v1/iam/organizations |
team Shell.tsx:2021-2022, :2206; lib/plans.ts:95-97 (ai app plans.ts:102-104, Orgs.tsx:42-45, :386) |
acting org = display name, fallback hanzo; checkout names no org |
token org |
ai app components/account/AccountLayout.tsx:140; OrgSwitcher.tsx:163-165, :319-320 |
a switch the AI client never hears; a hanzo fallback |
one switch through IAM [bug] |
ide src/hanzo/iam.ts:55-81; src/hanzo/account.ts:39, :81-95, :164-181 |
org list from userinfo owner + memberships; a picker nothing listens to |
token orgs, org; POST /v1/iam/switch |
console lib/api/account.ts:73-82, :191 |
owner from the JWT claim, then organization, then the sub prefix, then the brand org — on an admin host a UUID sub makes the UI SuperAdmin |
owner claim alone [bug] |
console lib/org-scope.ts:27-29, :57-60, :119-173; lib/api/client.ts:92; base-data/api.ts:228; csrf.ts:208 |
its own org state (brand org default) and X-Org-Id on every call |
token org; switch / assume |
console OrgPicker.tsx:145-179; ContextSwitcher.tsx:121-129 |
org list from the memberships API; a switcher that shows only the current org | token orgs |
console components/products/TeamModule.tsx:169-184; lib/api/team.ts:133-141 |
"invite" creates an account owned by the org (an I8 violation); "remove" deletes the account | IAM invitations; delete-membership [bug] |
console OrgOnboarding.tsx:60-70; lib/api/billing.ts:387-388, :545-550 |
POST /v1/iam/onboard; balance and top-up for the brand org by default |
POST /v1/iam/organizations; acting org |
cli crates/client/src/http.rs:25, :98 |
--as sent as X-Org-Id |
POST /v1/iam/switch, one cached token per org |
4.6 Records filed under admin
IAM inherited one owner for every platform record, so today admin/ prefixes
org ids, membership ids, platform applications and more. Each moves to its
namespace in §1 (I14); every lookup that names admin for a non-person record
goes with it.
| where | filed under admin today |
moves to |
|---|---|---|
internal/oidc/provision.go:152, :158; pkg/store/store.go:864; internal/organizations/organizations.go:135-136; internal/organizations/list.go:177, :206; pkg/store/store.go:837-846 |
every organization record, admin/<slug> |
org/<slug> |
pkg/store/membership.go:29, :86 |
every membership row, admin/<user>|<org> |
<org>/<sub>, the org's own data |
internal/bootstrap/bootstrap.go:261, :307, :331, :345; internal/seed/seed.go:99, :189, :212, :370; internal/oidc/mint.go:118; internal/featurestore/featurestore.go:87; pkg/store/store.go:686; server/server.go:261 |
platform applications, and "platform" decided as owner = admin |
app/<name>; platform = owner app |
authz claims.go:460 (signingOwners); seed certs |
the seven platform signing certificates | iam; signingOwners = {iam} |
pkg/store/store.go:816-820; internal/oidc/federation.go:658-663 |
platform identity providers, and the default provider owner | iam |
internal/oidc/challenge.go:57 |
sign-in challenges | iam |
pkg/schema/federation.go:31 |
federation states for platform providers | iam |
internal/oidc/token.go:338 (machineToken row), internal/oidc/masquerade.go:168-177 |
token rows of platform programs and of SuperAdmins | iam, keyed by sub |
internal/authz/authz.go:569-600; internal/oidc/masquerade.go:225-235 |
the SuperAdmin trail and tenantless audit rows | the org acted on, else iam |
| sessions, verification codes, factors, passkeys, chain wallets of SuperAdmins | per-account records keyed <owner>/<name> |
iam, keyed by sub (all accounts, §5 step 10) |
authz entity.go:172-173, :196-197 |
MemberOf(admin) and AdminOf(admin) answer Sudo: a SuperAdmin reads as a member of an org |
reserved namespaces have no members (I3) |
authz claims.go:491 |
reserved set {admin, built-in, app} |
{admin, id, org, app, iam}; built-in leaves once empty |
console lib/api/team.ts:93-105, lib/api/admin.ts:164-190, OrgPicker.tsx:131, :165, lib/account/org-state.ts:31; ai app organization/page.tsx:135, :260; id pkgs/auth/src/client.ts:940; gateway cmd/admin-api/sources.go:356-357 |
org and application records addressed as admin/<name> |
the slug: /v1/iam/organizations/<slug> |
cloud reader.go:45-53; cron/workflows.go:135; apps/commerce/catalog_rpc.go:39; sale_rpc.go:285 |
admin stated as the platform's own tenant on internal calls |
an explicit SuperAdmin caller; never an org named admin |
4.7 Account-level admin flags
Org admin is role(a, o) and nothing else (I15, I17). Every place that reads an
admin bit off the account, or infers org admin from where the account lives,
goes. Rows already in §4.1–4.5 are listed there: pkg/schema/user.go:116,
pkg/store/membership.go:413-418, internal/authz/authz.go:798,
internal/oidc/userinfo.go:93, ai util/permission.go:83-88, id
pages/Account.tsx:76, onboarding.ts:200, @hanzo/iam src/types.ts:114-115.
| where | reads or writes | replaced by |
|---|---|---|
internal/oidc/whoami.go:43 |
isAdmin in the whoami answer |
— |
internal/oidc/issuetoken.go:432 |
refuses acting for a user whose account flag is set | refuses when role(target, org) ∈ {owner, admin} |
internal/resolve/resolve.go:168-178 |
a key holder's IsAdmin from the account flag at home |
role(holder, key org) |
internal/registry/registry.go:446, :455 |
registry push for any account-flag admin in admin or hanzo |
superadmin(a) ∨ role(a, hanzo) ∈ {owner, admin} — owner-confirmed |
internal/scim/users.go:112, :197, :325, :364, :422, :437 |
SCIM's Hanzo extension reads and writes the account flag | the IdP's group mapping writes assigned(a, o, r) |
internal/users/users.go:280, :490-492 |
users create and update take Admin |
— |
internal/bootstrap/bootstrap.go:452, :484-486, :517 |
the upsert sets the flag (used to seed SuperAdmins, where it means nothing) | — |
internal/provision/provision.go:121-142, :693, :710 |
declared accounts provisioned with the flag by account type | declared accounts get assigned facts in their org |
authz claims.go:113-120, :303; entity.go:175-176, :199-200, :446 |
Claims.IsAdmin, a claim IAM's token never mints; OrgAdmin and AdminOf through it and through org == p.Org |
Role(o) from orgs |
cloud apps/account/account.go:804-818; apps/iam/roles_rpc.go:53-125 |
the account row's flag and "home ∧ isAdmin" decide standing | orgs |
commerce middleware/edgeauth.go:123-125; iammiddleware.go:68-71 |
X-User-IsAdmin written from the org-level bit; either header counts as org admin |
X-User-IsOrgAdmin alone |
gateway identity.go:146-158; cmd/admin-guard/authz.go:77-95; cmd/waitlist-guard/main.go:229 |
the Live bit from claims.IsAdmin; isAdmin && owner == org; IsAdmin || owner == admin |
Role(o); Sudo() |
chat src/presence/InviteModal.tsx:162; src/projects/ProjectModal.tsx:337 |
the directory's per-person admin flag shown as authority |
the roster's role in this org |
console lib/api/account.ts:105 |
isAdmin off the account |
Role(org) from the token |
4.8 Specs
- HIP-0519 rules 1 and 3 and the meanings of
X-Org-Id,X-User-OwnerandX-User-IsAdmin: amended by §2 (tenant fromorg, machine fromtype, owner is where the account lives). - HIP-0118 "membership of the reserved
adminorg": the predicate staysowner == admin; membership never confers it (I1, I2). - HIP-1045 (Orgs): its onboarding move and org creation are replaced by §3.
- HIP-0521 (Org Hierarchy): unimplemented; its SuperAdmin wording contradicts IAM and its ancestor billing is a second payer rule. Deleted with this HIP's Final.
5. Migration
Live state it starts from (SuperAdmin read of IAM through its admin API, and a
read of the hanzo roster, both on the day this HIP was written):
| what | count |
|---|---|
| orgs | 475 |
orgs carrying a founder |
395 (394 distinct values; one value is shared by two orgs) |
| orgs with no founder | 80 — the brand orgs, every org a SuperAdmin created, and 5 personal orgs made by direct grant |
| membership rows | 421 |
| — the account's own org (implicit today) | 401, of which 6 are programs |
| — direct grants: no invitation, no founder match | 11 |
| — founder-backed, in an org the account does not live in | 1 |
| — keyed by a UUID or by a key that names no account (read by nothing) | 8 |
| rows carrying an invitation | 0 |
accounts owned by hanzo |
16: 2 staff, 1 staff review account, 6 staff-made fixture persons, 4 customers, 3 programs |
| persons living in an org they founded | ~387 |
| first-party applications declaring a 168 h access token | most of the 105 in universe infra/k8s/iam/init_data.json |
records filed under admin that are not SuperAdmin accounts |
at least 896 live: 475 organization records and all 421 membership rows; the seed also declares 103 of 105 applications, all 7 signing certificates and all 6 identity providers there. Tokens, challenges, federation states and SuperAdmin-trail audit rows under admin are counted by the census |
accounts carrying the account-level isAdmin flag |
every founder moved into its org (~387), the 2 staff accounts in hanzo, every SuperAdmin, and each account the provision document declares by type |
Per-account and per-row detail is private data and lives in the private record the census writes, never in this document.
Classes. Every row the census reads lands in exactly one class, and each class has one action with one inverse.
| class | today | action | inverse |
|---|---|---|---|
| A | a person owned by admin outside the owner-approved SuperAdmin set |
moved(a, admin, id) + found home(a) |
move back |
| B | a person owned by hanzo, lux, zoo, pars or any org they did not found |
found home(a); moved(a, o, id) |
move back; the founded org stays empty |
| C | a person owned by the personal org they founded | founded(a, p) from the founder stamp; moved(a, p, id) |
move back |
| D | a person owned by a named org they founded | founded(a, o); found home(a); switched(a, o); moved(a, o, id) |
move back |
| E | a program owned by an org | moved(p, o, id) + assigned(p, o, r, owner), r the role its keys exercise (default member), each owner-vouched from the dry run |
move back |
| F | a direct-grant row | dropped, or — only where the owner attests the person was asked — invited + accepted with provenance owner-attested |
re-insert the row |
| G | a dead-key or UUID-keyed row | dropped | re-insert the row |
| H | an org with no founder | the owner names its founder → founded(a, o, owner); until then the nightly check lists it |
delete the fact |
| I | a person ledger person:<org>/<name> |
double-entry transfer to org:home(a) |
the reverse transfer |
| J | a subscription on an org the paying account is not a member of | re-point to the org that pays for it | re-point back |
| K | a username or email that collides inside id |
username: the later account takes a suffixed name; email: the owner decides merge or keep, never automatic | rename back |
| L | a record filed under admin that is not a SuperAdmin account |
re-filed under ns(x) (§1); name and content unchanged |
re-file back |
| M | an account carrying isAdmin |
never translated. The owner classifies each: needs platform authority → an explicit grantSuperAdmin; meant org admin → an owner-vouched role (founded for founders, assigned for staff and declared accounts); neither → nothing. Then the flag is deleted |
set the flag |
Steps. Each step is a dry run first (the plan, one line per change, with its evidence and its inverse), then the owner confirms, then it runs through IAM's audited admin API or an IAM migration that writes one fact per change. Every change is a fact, so every change is readable and reversible.
- Mail. IAM's email provider is configured from KMS (the private record names the KMS path, never the value). Invitations are the only normal route to an org role, and each proves a mailbox by a code IAM mails, so nothing below step 4 may run before a Playwright run delivers an invitation to a test mailbox and accepts it. [data; KMS, owner only]
- Census. A read-only SuperAdmin run lists accounts by owner and by
isAdmin, orgs with founder andisPersonal, membership rows with invitation, invitations, every record filed underadminby kind, applications withexpireInHoursandorgChoiceMode, commerce ledgers (everyperson:account with its balance), subscriptions by org, and evaluatesorgs,actingandpayerfor every account under this HIP next to today's values. Output: the plan above, classed A–M. [data, read-only] - Token lifetime. Every first-party application's
expireInHoursbecomes1in universecharts/app/values/hanzo/iam-provision.yaml, and universeinfra/k8s/iam/init_data.jsondrops its 168 h values. Revocation now bites within an hour. [data; affects sessions — owner approval] - Facts. Backfill
foundedfrom every founder stamp (classes C, D; the stamp is a storage id, the fact namessub),invited+acceptedfor owner-attested rows (F),foundedfor owner-named founders (H). Additive; no row and no claim changes. [IAM] - Close the write paths.
founderandisPersonalbecome server-set;POST /v1/iam/membershipsrefuses a non-member; invitations name one mailbox and one role; onboarding stops moving the caller. From here nothing can create access except founding and accepting. [IAM] - Personal orgs. Found
home(a)for every person in classes A, B and D. Seedswitched(a, o)for class D and for staff who today act inhanzo, so their acting org does not change. Class E programs get their owner-vouchedassignedfacts. [IAM] - Claims. IAM signs
owner= owner(a),org= acting,billing_account=org:payer, addsPOST /v1/iam/switch, and stops appending the assumed org toorgs.orgsitself is unchanged in this step, soorgs[0]still equals the account's own org and every current reader keeps working. At the same instant class-I balances transfer, and the refresh families of exactly those accounts are revoked so they re-sign in onto the new payer. [IAM + data] - Readers.
hanzoai/authzreadsSudofromownerandtype,Orgfromorg,Payerfrombilling_account,OrgAdminfrom the role inorgs, and dropsHome,EffectiveOrg,LedgerOrg,IsAdminand machine-by-empty-orgs. Cloud's edge mintsX-Org-IdandX-Billing-Account-Idfrom the claims and deletes the client's. Sites switch throughPOST /v1/iam/switchand delete their own org state (§4). Nothing readsadmin/as a prefix any more: IAM resolves each kind through its one namespace,signingOwnersholdsadminandiamtogether, the capability pin admits ownerappbesideadmin, the reserved-owner branch keys on the reserved set, sites address orgs by slug (/v1/iam/organizations/<slug>), and cloud's internal calls stop namingadminas the platform's tenant (§4.6). Nothing reads an account-level admin flag (§4.7). [edge, sites, IAM] - Namespaces. One IAM migration re-files class L, one transaction per kind,
writing one fact per kind with its count and a digest of the rows' content
before and after, the owner half excluded. Signing certificates move last,
after IAM has verified a token under each key from its new owner. Then
signingOwnersand the capability pin dropadmin, the/:owner/:nameorganization routes go, and I14 holds. [IAM] - Membership from facts.
orgsbecomes[home(a)] ⧺ members, never a reserved org. SuperAdmin tokens carryorgs = [], which step 7 made safe. Class M flags clear. [IAM] - Directory.
idjoins the reserved set. Classes A–E move, in batches:owner(a) := id, references keyed<owner>/<name>rewritten tosub, every per-account record re-filed underiambysub,moved(a, from, id)recorded. Collisions (K) resolve first. Each moved account signs in once more. [IAM] - Ledgers and plans. Class J re-points; the
person:ledger kind and the signup-org payer rule delete. [data + commerce] - Delete. Everything in §4 that steps 3–11 left unread is removed —
User.IsAdminlast — and the nightly check (§7) runs. [all]
What the migration preserves, and where it does not. Let before and after
name a predicate evaluated on the old rows and on the new facts.
- L1 (moving is invisible).
moved(a, from, id)changesowner(a)and nothing else:home,orgs,actingandpayerreadowner(a)only to telladmin, and programs apart, and a move of class B, C, D or E neither enters nor leaves any of them. So step 10 preserves all four for those classes. Class A leavesadminand loses SuperAdmin, which is the point. - L2 (payer is preserved except for B and I). Class C paid
org:pbefore (owner of its own org) and paysorg:home(a) = org:pafter. Class D paidorg:oand, withswitched(a, o)seeded, acts in and paysorg:oafter. Staff inhanzokeep acting in and payinghanzothe same way. Class B paid from the brand org they were filed under — a person ledger insidehanzo's ledger, or, inlux,zooandpars, the brand's own pool, because the signup-org rule exempts onlyhanzo— and after pays from its own org; step 6 moves the money in the same instant. This is the one intended change of payer. - L3 (acting changes for B only). Class B acted in the brand org they were filed
under — the four customers in
hanzoacted in Hanzo Inc's own workspace. After, they act in their own org. Intended. - L4 (access shrinks for F and G only).
memberafter ⊆memberbefore, and the difference is exactly the class F rows the owner did not attest and the dead rows of class G. - L5 (re-filing is invisible to authority). Step 8 is a map
fon records that changesns(x)and nothing else — not the name, not the content — and fixes every SuperAdmin account. Every predicate reads one of three things:ownerof an account (superadmin), whichffixes for SuperAdmins and never heldadminfor anyone else;(sub, slug)tuples (member,role,acting,payer), whichfpreserves because a membership row keeps its account, org and role and only changes the namespace it is filed in; and platform-ness of an application or certificate, which step 7 made readable from the new owner beforefruns. So for every predicateP,P(f(x)) = P(x). Tokenorgsclaims andorg:<slug>ledgers carry slugs, not ids, and do not change. The one intended difference: a reader that granted authority to a program because its owner wasadmin(the commerce and cloud rows of §4.4 and §4.6) stops doing so, since platform programs now live inapp. - L6 (org admin survives the flag). For every class M account whose flag meant
"administers o", step 3 or step 5 has recorded
founded(a, o)or an attestedassigned(a, o, admin), sorole(a, o) ∈ {owner, admin}before the flag clears in step 9. A flag with no attested role behind it ends — the only place L6 does not preserve access, and the census lists each such account. - Not preserved. Tokens minted before step 6 lack
organdbilling_account; after step 7 a reader refuses them, and they last at most one hour (step 2). Every moved account signs in once. Usage history keyed<org>/<name>is not rewritten: past facts stay as they were, and books read them throughmoved.
6. What needs the owner's approval
The owner's standing rule: no change to auth, IAM login or gate logic without an explicit ask. Every [IAM] and [edge] item below needs that ask. [data] runs through IAM's existing audited admin API, dry run first, owner confirms each batch. [sites] changes no authority, only which claim a page reads.
| # | change | step | kind |
|---|---|---|---|
| 1 | IAM email provider from KMS | 0 | data (KMS, owner only) |
| 2 | owner claim = owner(a); delete organization; userinfo drops isAdmin |
6 | IAM |
| 3 | org and billing_account from the facts; POST /v1/iam/switch |
6 | IAM |
| 4 | assume sets org, bills hanzo, leaves orgs alone |
6 | IAM |
| 5 | the fact log: organization-found, membership-assign, membership-revoke, organization-switch, account-move join the platform-written actions; membership rows become its fold |
3 | IAM |
| 6 | server-set founder/isPersonal; role-only POST /v1/iam/memberships; one-mailbox invitations with a role; delete /v1/iam/onboard and /v1/iam/admin/provision; restrict /v1/iam/admin/users/upsert |
4 | IAM |
| 7 | signup and federation land in id and found home(a); orgChoiceMode deleted; sign-in resolves in one directory |
4–5 | IAM |
| 8 | orgs = the fold; the principal's set and GET ?user= answer the same set |
9 | IAM |
| 9 | id reserved; accounts move; <owner>/<name> references rewritten to sub; per-account records under iam |
10 | IAM |
| 10 | BillingAccount shape rule and SignupOrg deleted |
11 | IAM |
| 11 | GET /v1/iam/check, read-only, SuperAdmin only |
12 | IAM |
| 12 | nothing but SuperAdmin accounts under admin: organization records to org, memberships into their org, platform applications to app, certificates, providers, challenges, federation states, tokens and tenantless audit rows to iam; /v1/iam/organizations/<slug> addressing; the /:owner/:name organization routes and every admin registry literal deleted |
7, 8 | IAM |
| 13 | org admin is role(a, o) only: User.IsAdmin stops being read (userinfo, whoami, principal, keys, token exchange, registry push, SCIM, provisioning, upsert) and is deleted; assume grants no role |
7, 9, 12 | IAM |
| 14 | access-token lifetime at most 1 h for every first-party application | 2 | data (auth-affecting: approve) |
| 15 | authz and account libraries read claims only (IsAdmin and org == p.Org leave OrgAdmin/AdminOf; signingOwners gains iam; the capability pin admits app; the reserved set becomes {admin, id, org, app, iam}); cloud's edge mints from claims and drops cliOrg; cloud's internal calls stop naming admin as a tenant; commerce drops its own JWT validation and EdgeAuth; gateway guards read Sudo() and roles |
7 | edge |
| 16 | census, attestations (founders, staff roles, declared accounts' roles), row drops (POST /v1/iam/delete-membership), ledger transfers, plan re-pointing |
1, 3, 6, 11 | data |
| 17 | every §4.5 site row; §4.6 and §4.7 site rows (slug addressing, no account-level admin flag) | 7 | sites |
| 18 | grantSuperAdmin / revokeSuperAdmin as the only writers of admin accounts; genesis only while admin is empty; every other create path refuses admin (R2) |
4 | IAM |
| 19 | every creation path in §8.1 yields id/<account> + personal org and nothing else; programs move to id with an explicit assigned (R1) |
4, 5, 10 | IAM |
| 20 | ADMIN_NAMESPACE_INVARIANTS required for merge in IAM, and its static half in authz, account, cloud, ai, commerce, gateway, @hanzo/iam and the sites (R7) |
3 | IAM, CI |
| 21 | registry push = superadmin(a) ∨ role(a, hanzo) ∈ {owner, admin} (R8) |
7 | IAM (owner-confirmed) |
| 22 | minting on behalf (POST /v1/iam/tokens/issue, token exchange) refuses every admin/* target — a SuperAdmin acts only on their own sign-in — and IAM_ADMIN_TOKEN_EXCHANGE_APPS is deleted |
4 | IAM |
| 23 | a SuperAdmin account signs in with a second factor, and appointment and dismissal take a sign-in with it | 4 | IAM |
The rows marked [bug] in §4 are live defects that stand on their own; each can ship ahead of this sequence, and each still needs the same approval when it is tagged IAM or edge.
The account's identity does not change in step 10: sub stays, and only its
<owner>/<name> address does. References keyed by sub need nothing. References
keyed <owner>/<name> are rewritten: IAM membership rows, IAM chain-wallet
bindings (pkg/schema/wallet.go), commerce person: ledgers (class I), and any
cloud store the census finds keyed that way. Audit and usage history are read
through moved, never rewritten.
7. Verification
Property tests (IAM pkg/store, testing/quick, no new dependency; they
are part of ADMIN_NAMESPACE_INVARIANTS, §8 R7). Generate
random fact logs of every kind; after each append, assert I1–I9, I12 and
I14–I17 on the fold, the last three as independence: appending any fact about
(a, o) leaves role(a, o′) for o′ ≠ o and superadmin(a) unchanged, and
appending assumed leaves every role unchanged; assert the fold is deterministic and order-independent within a timestamp;
assert L1 (a moved fact leaves home, orgs, acting, payer unchanged); and
assert the refusals: a non-member switch, an invitation into a personal org, a
revoke of the last owner, a founded into a reserved org, a SuperAdmin founded
for themself.
Nightly check. A CronJob the operator declares calls two read-only, SuperAdmin-only reports and files every non-empty answer on the platform audit trail and the status board:
| report | invariant | answers |
|---|---|---|
GET /v1/iam/check |
I1 | every account in admin, against the owner-approved SuperAdmin roster kept in the private record |
| rows = fold | every membership row with no founded or accepted fact behind it |
|
| I3 | every account whose orgs names a reserved org, or an org no fact grants — authority from where an account lives |
|
| I2, I4, I5 | a SuperAdmin with an org; a person in id without exactly one personal org; a personal org with a second member |
|
| I6, I7 | every org with no founder, or no owner | |
| I8 | every account filed anywhere but id or admin |
|
| I14 | any record under admin that is not a SuperAdmin account — every kind, not just accounts |
|
| I17 | any account with an account-level admin flag set | |
| I2 | any org role held by a SuperAdmin; any roster listing one | |
GET /v1/billing/check |
I10 | every ledger account not of the form org:<slug>; every deposit into org:hanzo paid by a non-member of hanzo — customer money in Hanzo's wallet |
| I11, J | every subscription on an org that does not exist, is reserved, or is not an org its paying account belongs to | |
| I9 | every debit whose org is neither in its account's orgs nor hanzo under an assumed fact |
Each step, live. A step is done when its check reads true at the running pod, not when its pipeline is green.
| step | verified by |
|---|---|
| 0 | a Playwright run sends an invitation to a test mailbox, reads the code from it and accepts; the org appears in the invitee's switcher |
| 1 | census totals equal GET /v1/iam/organizations and GET /v1/iam/users totals |
| 2 | every application reads expireInHours ≤ 1; a fresh token's exp − iat ≤ 3600 |
| 3 | GET /v1/iam/check rows-vs-fold differs by exactly the class F and G rows; tokens for a sampled customer, staff member and SuperAdmin decode byte-identical in orgs before and after |
| 4 | a non-member POST /v1/iam/memberships → 403; a founder in a create body is ignored; a multi-use invitation is refused; Playwright drives invite → mail → /join → the org appears in the switcher |
| 5 | I4 violations = 0 |
| 6 | the three sampled tokens carry the expected owner, org, billing_account; a non-member switch → 403; an assume leaves orgs unchanged and writes its row under the tenant; class I ledger totals are equal before and after |
| 7 | curl api.hanzo.ai with a forged X-Org-Id answers for the token's org; Playwright on console, id, team, ai, chat and ide: switch org in one, and every surface shows that org and its balance; admin.hanzo.ai lists every org and enters one only through assume |
| 8 | per kind: count moved = count read, content digests equal (L5); GET /v1/iam/check I14 empty; tokens minted before and after verify under every key; the census evaluation of every predicate for every account is unchanged; Playwright: sign-in to console, admin console, id and chat, and an org page reached by slug |
| 9 | a SuperAdmin token carries orgs = [] and the admin console still admits it; I17 empty; every class M account with an attested role still administers its org (L6) |
| 10 | per batch: sub unchanged, census evaluation of acting and payer unchanged (L1), a sampled account signs in through Playwright |
| 11 | no person: ledger remains; the sum of all balances is unchanged |
| 12 | both reports empty for seven consecutive nights; then §4 deletes |
8. Hard requirements
A change that implements any part of this HIP is refused if it breaks one of these.
-
R1 — Deny by default. Every way an account comes into being yields exactly
id/<account>, its per-account records underiam, and for a person its personal org withfounded(a, home(a)). Nothing else: no role in any other org, no record underadmin, no flag. No account-leveladmin,isAdmin, role or group field exists at all (I17, I19). §8.1 lists every path. -
R2 — One way into
admin.grantSuperAdmin(actor, target)withsuperadmin(actor)— oractor = genesiswhileadminis empty — audited, is the only code that createsadmin/<account>(I18). In IAM the constructor of anadminaccount is unexported, lives in one package and has one caller;users.Create, the only other writer of an account, refuses theadminnamespace. R7 enforces both. -
R3 — No translation. A legacy account-level admin flag never becomes an
adminaccount. The owner classifies each (§5 class M): platform authority is an explicitgrantSuperAdmin; org-admin intent is an owner-vouchedrole(a, o); anything else is dropped. Then the flag is deleted. -
R4 — One check. Every authorization check is
allow(§2):superadmin(a)for a platform resource,role(a, o) ⊇ need— orsuperadmin(a) ∧ assumed(s) = o— for an org resource. No check reads an account'sowner, namespace, admin flag or founder, an org'sfounder, an application'sorganization, ororgs[0]. A SuperAdmin is not an org admin and holds no implicit role. -
R5 — Provisioning guard. For every path in §8.1 a test asserts, after the path runs:
¬∃ admin/<new>,¬adminFlag(new), andorgs(new) = [home(new)]for a person (plus exactly the org whose invitation it redeemed, where the path redeems one) ororgs(new) = [o]for a program assigned intoo. One more test drives an ordinary account through every onboarding path in turn — signup, invitation, OAuth, an org's provider, wallet, password reset, key mint, org creation, switch, and an assume attempt — and asserts it ends withsuperadmin = falseand no role outside the orgs it founded or whose invitations it accepted. -
R6 — Invitations are the route. A person gains a role in an org they did not found only by accepting an invitation (an org's provider binding is one). Mail comes first: §5 step 0.
-
R7 —
ADMIN_NAMESPACE_INVARIANTS. A named suite in IAM's CI (the roothanzo.yml), required for merge, fails when any of these breaks — including when a schema, model or migration brings back an account-level admin field:# invariant how it is checked 1 admin/<a>⇒superadmin(a)(I14)every write path against a store, then a scan of every kind for a record under adminthat is not a SuperAdmin account2 superadmin(a)⇒ no org role (I2)property test over random fact logs; assumeleaves every role unchanged3 no account-level admin field (I17) reflection over every type reachable from schema.Userand over every migration: a field, tag or column matchingadmin,isAdmin,superAdmin,globalAdmin,roleorgroupson an account fails4 creation ⇒ no admin record, no role outside its own org (I19) the R5 tests, one per §8.1 path 5 a role is scoped to (account, org) (I15) property test: a fact about (a, o)leavesrole(a, o′)unchanged6 a role never makes a SuperAdmin (I16) property test: no role fact changes superadmin(a)7 only grantSuperAdmincreatesadmin/<a>(I18, R2)static: a go/astwalk of the module finds exactly one statement that sets an account's owner to theadminnamespace or keys oneadmin/…, insidegrantSuperAdmin; dynamic: every other create path refusesadminThe static half of 3 and 7 — no read of an account-level admin bit, no
owner == "admin"outside the oneSudo()reader — runs inhanzoai/authz,hanzoai/account, cloud, ai, commerce, gateway,@hanzo/iam, and every site in §4.5. -
R8 — Registry push is
superadmin(a) ∨ role(a, hanzo) ∈ {owner, admin}(owner-confirmed). -
R9 — Done means deleted. Before any implementing change merges, blue builds it and red audits every call site in §8.2 against R4, and the change names which sites it deletes. The migration is done when every §8.2 site is gone or reduced to
allow— not when records have moved.
8.1 Every way an account is created
| path | today (IAM v1.34.120) |
after |
|---|---|---|
| email signup | internal/oidc/signup.go:320-391: the application's org, then charter moves it |
id + personal org |
| signup with an invitation | signup.go:149-160, :303-313 |
id + personal org + accepted(a, n) |
| invitation, existing account | internal/oidc/accept.go:83-190; invite.go:160-205 |
accepted only; no account created |
| OAuth / social | internal/oidc/federation.go:591-622 |
id + personal org |
| an org's identity provider | federation.go:591-622 |
id + personal org + accepted(a, n_p) |
| wallet sign-in | internal/wallet/verify.go:258-288: the application's org, a direct store write |
id + personal org, through users.Create |
| API user create | internal/users/users.go:56, :239-285: takes Admin; a SuperAdmin may create in admin |
id, no Admin input; refuses admin |
| SCIM | internal/scim/users.go:319-325: isAdmin from the Hanzo extension |
id + accepted through the org's provider binding |
| enterprise modules | internal/featurestore/featurestore.go:64-67 |
through users.Create |
| service-token upsert | internal/bootstrap/bootstrap.go:67, :514: any org, isAdmin settable |
deleted |
| declared accounts | internal/provision/provision.go:693-745: isAdmin by account type |
id + owner-vouched facts |
| service account | internal/serviceaccounts/serviceaccounts.go:157, :340: filed in the org; admin open to a SuperAdmin |
id + assigned(p, o, r, actor); refuses admin |
| onboarding credential | internal/oidc/provision.go:210 (<slug>-default) |
deleted with onboarding; the service-account path makes programs |
| password reset / recovery | internal/oidc/password.go:128-240 |
creates nothing; the guard asserts roles and namespace unchanged |
| migration | §5 steps 3, 5, 10 | facts only; class M never produces an admin account |
| SuperAdmin | the upsert above; the users API as a SuperAdmin | grantSuperAdmin only |
8.2 Every place that reads authority from something other than allow
The audit list for R9, read at the commits in §4. Each is deleted or reduced to
allow.
owner or namespace = admin, read as authority outside superadmin
- IAM:
internal/registry/registry.go:446,:455;internal/seed/seed.go:212;internal/bootstrap/bootstrap.go:261,:331;internal/oidc/mint.go:118;internal/featurestore/featurestore.go:87;pkg/store/store.go:686;server/server.go:261;internal/serviceaccounts/serviceaccounts.go:340;internal/organizations/organizations.go:135,:294; authzclaims.go:460,entity.go:172-173,:196-197. - cloud:
auth_identity.go:475-477;apps/admin/admin.go:208-211,:368;apps/commerce/catalog_rpc.go:39;sale_rpc.go:285;apps/marketplace/listings.go:387-389;apps/ci/ci.go:204-209;reader.go:52;apps/gateway/edge/policy.go:213-230. - ai:
util/permission.go:53-60,:99-101;controllers/zap_application-deploy.go:121;zap_chat-graph-crud.go:920;controllers/cloud_usage.go:138. - commerce:
auth/iam.go:274-276;middleware/iammiddleware/iammiddleware.go:252-258;middleware/edgeauth.go:193-205;middleware/platformonly.go:104-105;api/billing/authz.go:31-39;api/billing/tier.go:26-35. - gateway:
cmd/admin-guard/authz.go:101-104;cmd/admin-api/handlers.go:49,:68,:107;sources.go:172;cmd/waitlist-guard/main.go:229,:479,:644; authzv1.10.29claims.go:210-230. - sites: console
config/index.ts:322-324,lib/auth/admin.ts:29-38;@hanzo/iamsrc/auth.ts:196; team and ai apphooks/useTiers.ts:48-57,:103-104.
an account-level admin flag
- IAM:
pkg/schema/user.go:116;pkg/store/membership.go:413-418;internal/authz/authz.go:798;internal/oidc/userinfo.go:93;internal/oidc/whoami.go:43;internal/oidc/issuetoken.go:432;internal/resolve/resolve.go:168-178;internal/scim/users.go:112,:197,:325,:364,:422,:437;internal/users/users.go:280,:490-492;internal/bootstrap/bootstrap.go:452-517;internal/provision/provision.go:121-142,:693,:710;internal/oidc/provision.go:139,:173,:181-183; authzclaims.go:113-120,:303,entity.go:199-200,:446. - cloud:
apps/account/account.go:804-818;apps/iam/roles_rpc.go:53-125. - ai:
util/permission.go:83-88. - commerce:
middleware/edgeauth.go:123-125;iammiddleware.go:68-71. - gateway:
identity.go:146-158;cmd/admin-guard/authz.go:77-95. - sites: id
pages/Account.tsx:76,pkgs/onboarding/src/service/onboarding.ts:200; chatsrc/presence/InviteModal.tsx:162,src/projects/ProjectModal.tsx:337; consolelib/api/account.ts:105;@hanzo/iamsrc/types.ts:114-115.
founder
- IAM:
internal/oidc/provision.go:173;internal/organizations/organizations.go:149,:198; branchorgsstore.Joined(not merged).
where the account lives, read as membership or admin of that org
- IAM:
pkg/store/membership.go:220-271,:366-384,:405-408;internal/memberships/memberships.go:206-208;pkg/store/billing.go:37-63; authzclaims.go:185-190,entity.go:175-176. - cloud:
auth_identity.go:326-338;apps/team/account.go:519-522,:604-606;apps/agents/conversation.go:207;apps/account/embed.go:139. - ai:
internal/iam/jwt.go:91-93,:111-118;routers/org_resolver.go:43. - commerce:
iammiddleware.go:89-96.
the owner claim, which names the application's org
- IAM:
internal/oidc/jwt.go:332,:389;internal/oidc/userinfo.go:78. - cloud:
token_validator.go:34-37,:118;cli/auth.go:121-124;internal/iam/exchange.go:127-155. - commerce:
middleware/edgeauth.go:113-114;auth/iam.go:253-261. - gateway:
cmd/admin-guard/authz.go:64,main.go:299;cmd/waitlist-guard/main.go:285. - sites: console
lib/api/account.ts:73-82,:191; chatsrc/data/session.tsx:132-145; idesrc/hanzo/account.ts:39,src/hanzo/iam.ts:57-58; idapps/account/src/worker.js:823.
a SuperAdmin entering an org without assume, or assume granting a role
- IAM:
internal/oidc/masquerade.go:158-160; authzclaims.go:329-331. - cloud:
auth_identity.go:490-501;middleware_identity.go:340-344;apps/gateway/gateway.go:143-150;apps/platform/apps.go:660-690. - ai:
controllers/org_resolver.go:165-166. - commerce:
middleware/edgeauth.go:134-141. - gateway: authz
v1.10.29claims.go:263-280.
9. Agencies, brands and apps built on Hanzo
A customer builds on Hanzo for customers of their own: an agency runs its clients'
orgs, a brand sells Hanzo under its own name, an app builder ships an app to its
users. All three are the same model, kept flat: orgs relate by facts, never by
nesting, and people are still id accounts that belong to orgs by founding or
accepting.
9.1 The relationship: manages
manages(g, c, r) says org g runs org c for it, and g's people may act in
c at most as role r. It is one fact, and it is the only relation between two
orgs.
- Made one of two ways. An owner or admin of
gcreates a client org:POST /v1/iam/organizations {name, manager: g}recordsfounded(g, c)andmanages(g, c, owner)— the agency holds the org until it hands it over. Or an owner of an existing orgcacceptsg's management invitation (POST /v1/iam/invitations {org: c, manager: g, role: r}byg, accepted by an owner ofc), which recordsmanages(g, c, r)withr ≤ admin. - Handed over.
ginvites the client's person intocasowner; once they accept,manages(g, c, owner)is replaced bymanages(g, c, admin)(I21). The client can always remove the agency; the agency can never remove the client. - Ended by an owner or admin of either side:
unmanaged(g, c, by). It bites at the next mint, likerevoked.pays(g, c)ends with it. - What it confers. A member
xofgswitches intocthroughg(POST /v1/iam/switch {org: c, via: g}). The token then carriesorg = candvia = g,cstays out oforgs, andxacts incasmin(role(x, g), r)(§2). Each entry and exit is a row onc's trail, so a client reads which agency person was in their org and when (I24). A plain member ofgnever gets more incthan member; an agency admin never gets owner of a client that has an owner account. - What it does not.
managesis not membership (I22): nobody atgis onc's roster, holds a role there, or seescin their switcher except as a managed client. It does not compose (I23): ifcmanagesd,g's people reachdonly through their ownmanages(g, d). It never namesadmin, and a SuperAdmin still reaches any org only through assume. - A client's end users are
idaccounts invited intoc, with their own personal orgs, exactly as anyone else (I1–I20). Their roles arec's facts, not the agency's.
9.2 Money
The seller of a call is the org that registered the application it came through: Hanzo for Hanzo's own apps, the agency for its branded apps, the builder for a custom app. Who pays is still one equation (§1); three facts extend it.
| fact | meaning | who may write it |
|---|---|---|
pays(g, c) |
the agency pays for its client's usage | an owner of g, with manages(g, c) |
funds(v, x) |
a seller pays for every call through its own application x |
an owner of v, the org x is registered in |
a price list of v |
v resells: its retail price per item, never below its wholesale |
an owner of v; Hanzo sets v's wholesale |
Each debit posts double-entry, in exact cents, in one transaction:
| case | payer | debit | credits |
|---|---|---|---|
| Hanzo's own app | acting org | list price, cash first, then credit | Hanzo revenue |
| a seller that does not resell | acting org | list price, cash first, then credit | Hanzo revenue |
| a seller that resells, client pays | acting org | the seller's retail, cash only | Hanzo revenue: the seller's wholesale; margin:<seller>: retail − wholesale |
the agency pays (pays) |
the agency | the agency's wholesale, cash first, then credit | Hanzo revenue |
the seller pays (funds) |
the seller | the seller's wholesale, cash first, then credit | Hanzo revenue |
The cash and credit rules hold unchanged (I26): cash is money a customer paid and
the only money that buys paid-model work or produces margin; credit is money Hanzo
minted, and only Hanzo mints it. A resold debit draws cash only, so a seller's
margin is always cash, payable by payout. A seller who wants to give its customers
free usage funds it (funds or pays); it cannot mint credit.
The platform ledger (§3, Pay) gains one line per seller: margin earned, margin paid out, margin owed. Owed margin is a liability Hanzo carries for the seller, beside the prepaid it carries for every org; neither is Hanzo revenue, and Hanzo's revenue is the list and wholesale portions alone (I27).
9.3 White-label brands
A brand is a record an org owns: its name, theme, logo, legal pages, support address, mail sender, the hosts it serves on, the issuer its tokens carry and the certificate that signs them. Hanzo, Lux, Zoo and Pars are brands the same way a customer's is.
- Hostname is the brand. Every surface resolves
brand(h)from the request's host, through IAM (GET /v1/iam/brands?host=, public fields). A host with no brand serves nothing — there is no default brand (I28). Everything under that host names only that brand: the login and signup pages, every mail, the token issuer (I29). Nothing says "Hanzo" under a customer's brand. - One application per brand surface. Each brand has its IAM applications,
named
<org>-<app>and registered in the brand's org; a token names the application, so it names the brand. Login, signup, verification, reset and invitation mail render from the brand of the application in play. - Custom domains. A brand's owner adds a host; IAM issues a DNS-01 TXT
challenge by the same proof custom sites use (cloud
internal/fqdn, as inapps/projects/domains.go:312), recordshosted(b, h)once it verifies, and the host is routed and certified by the same ingress and certificate path. A brand sends mail only from a sender domain it has proven; until then it sends none, rather than sending as Hanzo. - The end user's account. A person who signs up under a brand gets an
idaccount and a personal org like anyone else. The brand is where they signed in, not where the account lives: the same email under another brand is the same account, and it signs in there separately. - No crossing (I30). A session is a cookie on one brand's IAM host, so there is no single sign-on across brands. A token minted for a brand's application carries only the person's personal org and the orgs founded under that brand; a switch into another brand's org is refused. An org's brand is the brand it was founded under, and a client org an agency creates takes the agency's brand.
9.4 Apps built on Hanzo
An app builder is an org v that registers an application x (v-x) under one
of its brands and ships it. Its users sign up through x into id, each with a
personal org, and belong to v's customer orgs only by invitation — or the
builder creates those orgs as their agency (manages). Billing is one of the two
rows in §9.2: funds(v, x) and the builder pays at wholesale for everything
through x, or each user's acting org pays at v's retail and v earns the
margin. The builder's own backend is a program in id with a role in v, and
its keys name v. A program never acts for users by membership; it acts as
itself in v, or through token exchange on a user's behalf.
9.5 Flows
| flow | call | facts |
|---|---|---|
| agency creates a client org | POST /v1/iam/organizations {name, manager: g} by an admin of g |
founded(g, c), manages(g, c, owner), brand(c) = brand of g's app |
| agency hands the org over | POST /v1/iam/invitations {org: c, email, role: owner}; the client accepts |
accepted(client, n); manages(g, c, admin) |
| existing org takes an agency | POST /v1/iam/invitations {org: c, manager: g, role: r} by g; an owner of c accepts |
manages(g, c, r), r ≤ admin |
| agency person works in a client | POST /v1/iam/switch {org: c, via: g} |
switched(x, c, via g); entry row on c's trail |
| either side ends it | POST /v1/iam/unmanage {org: c, manager: g} |
unmanaged(g, c, by); unpaid(g, c) |
| agency pays for a client | POST /v1/billing/pays {org: c} by an owner of g |
pays(g, c) |
| seller pays for its app | POST /v1/billing/funds {application: x} by an owner of v |
funds(v, x) |
| seller resells | PUT /v1/billing/prices by an owner of v |
a price list of v, each item ≥ its wholesale |
| brand created | POST /v1/iam/brands {name, theme, logo, legal, support} by an owner of v |
branded(b, v) |
| custom domain | POST /v1/iam/brands/{b}/hosts {host}, then …/verify |
hosted(b, h) after the TXT record proves it |
| builder ships an app | POST /v1/iam/applications {name: v-x, brand: b} by an admin of v |
the application, registered in v |
| margin paid out | POST /v1/billing/payouts by an owner of v |
a payout from margin:<v>, cash only |
9.6 Delete list additions
| where | what goes | replaced by |
|---|---|---|
cloud apps/admin/core/scope.go:14-18, :65-73 |
Descendants: a sub-org subtree waiting for an IAM parent link |
manages facts, one hop |
cloud apps/admin/core/state.go:35-60; apps/admin/admin.go:792-815 |
ADMIN_WL_TENANT_ORGS: an env allowlist deciding which resellers' admins reach the cockpit |
manages + role |
cloud brand/brand.go:74-89, :169 |
the brand and issuer map in code | brand records read from IAM |
console src/config/index.ts:198-250 |
BRANDS and HOST_BRANDS in code, customer brands (7stars, yotoda) among them; brandFromHost defaults to Hanzo |
brand records; no default |
chat src/brand.ts:135-208 |
brand list in code; an unknown host is Hanzo's | brand records; no default |
id pkgs/shared/src/org.ts:53 (DEFAULT_TENANTS); universe infra/k8s/id/configmap.yaml (SPA_IAM_TENANT_CONFIG_JSON) |
two copies of the host → tenant map for the login page | brand records |
commerce checkout/org_resolver.go:295-311, :383 |
brandForHost; an unknown host falls back to COMMERCE_DEFAULT_ORG (hanzo) |
brand records; no default |
IAM internal/oidc/jwt.go:429-430; internal/oidc/invite.go:35 |
isShared applications and their org-qualified audience: one app serving many orgs |
an application belongs to one org and one brand |
IAM internal/bootstrap/bootstrap.go:604-612 |
resolveCert: the signing certificate named cert-<org> from the application's org |
the certificate the brand record names |
| HIP-0521 | a parent edge that nests orgs and moves the payer by ancestry | manages, pays |
9.7 Migration of the existing brand orgs
The census (§5 step 1) also lists every brand in the code maps above — Hanzo, Lux, Zoo, Pars and Bootnode in cloud; 7Stars and Yotoda in console; the certificate set for Adnexus — with their hosts, issuers and assets, and every account filed under a brand org.
- Brand records. One per brand, owned by its org, carrying the hosts, issuer, certificate and assets the maps state today. Additive; every map keeps serving. [IAM, data]
- Readers. Cloud, console, chat, id and commerce resolve the host through IAM and stop defaulting to Hanzo; the maps in §9.6 delete in the same change. An unknown host answers 404. [edge, sites]
- Pooled payer (H2). Self-signups filed under
lux,zoo,parsandbootnodespend the brand org's pool today, because the signup-org rule exempts onlyhanzo. They are class B (§5): each moves toidwith its own org, and from step 6 each pays from it. The census reports, per brand org, what non-members drew from its pool; the owner decides write-off or recovery, and books records the decision. [data] - Brand orgs are ordinary orgs. Their staff hold roles by founding or an owner-vouched fact; their applications move into their own org's namespace (§4.6); none manages another unless the owner records it. [data]
- Customer brands (7Stars, Yotoda, and any org in
ADMIN_WL_TENANT_ORGS) become brand records owned by the customer org; their cockpit access becomesmanagesfacts for the client orgs they actually run, owner-vouched. [data]
9.8 Approval
| change | kind |
|---|---|
manages / unmanaged, the management invitation, switch … via, via in the token, the entry trail |
IAM |
brand records, hosted by DNS proof, brand-scoped orgs, issuer and certificate from the brand, refusal of cross-brand switches, per-brand mail sender |
IAM |
isShared deleted; one application, one org, one brand |
IAM |
pays, funds, price lists, the margin account and payouts |
commerce (money: approve) |
| host → brand from IAM in cloud, console, chat, id, commerce; no default brand | edge, sites |
brand records and cockpit manages facts for existing brands; H2 write-off or recovery |
data |
9.9 Verification
| what | how |
|---|---|
| a client org without silent membership | Playwright: an agency admin creates c; c's roster is empty of agency people; the admin's switcher shows c as managed; a switch through g writes an entry row on c's trail |
| handover and revocation | the client accepts ownership; the agency's role in c becomes admin; the client ends management and the agency's next token cannot enter c |
| no chains | g manages c, c manages d; a person of g switching into d is refused |
| money | a resold debit moves the payer's cash by retail, Hanzo revenue by wholesale and margin:<seller> by the difference, summing to zero; the same call with only credit is refused 402; funds and pays debit the seller and the agency |
| brand | a customer host verifies by TXT, serves TLS, and its login, signup, reset and invitation mail contain no "Hanzo" (a DOM and mail-body scan); an unverified host and an unknown host answer 404 |
| no crossing | one person, two brands: each brand's token lists only its own orgs and the personal org; signing in to one does not sign in to the other |
| nightly | GET /v1/iam/check adds I21–I25 and I28–I30; GET /v1/billing/check adds I26 and I27 |
Rationale
The trade-off. This design gives up per-request org selection by header and instant revocation of a live token. A switch costs one round trip to IAM and a new token; the acting org is one per account, not one per tab; a revoked membership stops at the next mint, at most an hour later (keys stop at once). In return the four answers exist in one place and travel signed, so no reader can compute a different one, and the whole class of defects in §4 — a header trusted, a claim misread, a fallback to the wrong org, a list order read as authority — has nowhere left to live. It also gives up silent adds: a teammate joins by accepting, which needs mail to work. SuperAdmins keep two accounts, one to administer and one to work in. Each moved account signs in once. All of these are one-time or bounded costs; the defects they remove recur.
Why an org's identity provider does not own accounts. Every account lives in
id, so an org with its own provider admits people through it and removes them
through SCIM, and can require its domain to sign in through it, but cannot delete
a person's account or their own org. The alternative — accounts owned by an org —
is a second kind of directory, a second home rule and a second deprovisioning
path, and it made "who can delete this login" depend on how the person first
signed in. One directory keeps R1 true for every creation path.
Why a directory and not the personal org as home. Today onboarding moves an
account into the org it founds. That braids three things — where a login lives,
what it may act in, and what it pays from — into one field, owner, so founding
an org re-keys the account and strands its tokens, memberships and keys. With id
the account never moves (I12), the personal org is just the org it founded, and
paying follows acting (L1, L2).
Why facts. Access is a history — founded, invited, accepted, revoked — and a
row that is overwritten cannot say how it came to be. With the log, a membership
row is a cache of the fold and the nightly check can recompute it. The parked
orgs branch kept rows that exist but confer nothing; this design has no such row.
Security Considerations
- Order is load-bearing.
Machine()reads an emptyorgsas a machine; step 9 gives SuperAdminsorgs = [], so step 7 (Program()fromtype,Sudo()fromowner) must be live first or every SuperAdmin loses authority. Likewise the SDK's SuperAdmin test readsadmininorgsand must move before step 9. Step 8 must follow step 7 for the same reason: a reader still resolving an org asadmin/<slug>, a JWKS walk withoutiaminsigningOwners, or a capability pin that admits only owneradminwould read the re-filed records as absent. - The owner-claim fix closes an escalation. Today a person signing in through
an application owned by
admincarriesowner = admin, and every reader that trustsowner(commerce EdgeAuth, the gateway guards, ai's fallback) treats them as SuperAdmin. Step 6 makesownerthe account's own directory; until then those readers are live defects (§4 [bug]). - Header masquerade. authz
EffectiveOrgand cloudActslet a SuperAdmin enter any org by sendingX-Org-Id, which never reaches IAM's assume trail. It is deleted, not narrowed: assume is the only entry (I9). - Mail is a hard dependency. Invitations prove a mailbox by a code IAM mails. IAM has no live email provider; until one is configured from KMS, step 4 leaves no way to join an org. Configure it before step 4.
- Money moves once, with conservation. Class I transfers are double-entry and checked by total. Tokens minted before step 6 still debit the old person ledger for at most an hour; revoking exactly those accounts' refresh families makes them re-sign in onto the new payer.
- Creation is the escalation surface. §8.1 lists sixteen paths that create or
touch an account; six of them today can file one under
adminor set an admin flag. R1, R2 and R5 close them by construction and by test, and R7 fails the build if one comes back. - A minting client is a SuperAdmin's equal. A client on
IAM_TOKEN_EXCHANGE_APPSandIAM_ADMIN_TOKEN_EXCHANGE_APPS(the console) can issue a token for any account,admin/*included, from its client secret alone, so that secret carries every SuperAdmin's authority on every surface that accepts such a token. Appointment and dismissal already refuse any token that is not the SuperAdmin's own sign-in from the last ten minutes (the token row of an authorization-code or device grant); §6 row 22 removes the rest: no on-behalf mint names anadmin/*account, and the admin list goes. Until then the console secret is held as a SuperAdmin credential. - A dismissed SuperAdmin's token outlives the dismissal elsewhere. IAM refuses
it at the next request, because the Guard reads the account; cloud and every
reader that validates a JWT locally honour it until
exp. The fix is the one-hour lifetime of §5 step 2, never a check in cloud (HIP-0519). A token also never names an account created after it was issued, so a name taken again does not revive the tokens its last holder carried. - A bearer is an access token. An id_token proves a sign-in to the client it
was issued to; IAM accepts as a credential only a token of type
access-tokenwith an audience and an issuer it mints under, and an assume never outlives the token it re-scopes. - A released assume ends at IAM at once, elsewhere within the hour. IAM's Guard looks up the stored record of every assumed token and refuses one whose record release deleted; it looks up nothing for any other token. Cloud and the gateway validate locally and add no check: the one-hour lifetime of §5 step 2 bounds an assumed token there, as it bounds a dismissal (HIP-0519).
- A session keeps the moment its person signed in. Every authorization code
records when its person proved who they are, and a session minted from a code
(
/v1/iam/signin) or a code minted from a session (a credential-lessPOST /v1/iam/login) carries that moment forward, never the time of the request, so no chain of exchanges refreshes the ten-minute step-up. Signin takes only a code the platform's own application requested, redeemed with its S256 verifier. A session answers only with S256-bound codes, and on an application's own host only for the application it was opened under; the identity provider's host keeps single sign-on. Signing out ends every session the person holds, on every host. - An address is spoken of only to its holder. Signup checks the code mailed to an address before it says the address already has an account, so naming an address teaches a caller nothing.
- Support is Hanzo's cost. Under assume the debit lands on
org:hanzoand the tenant's own audit trail shows who was inside and when. - Replacement, never deletion (HIP-0519).
cliOrgand every recomputation in §4.3 go in the same change that reads the claim, so the boundary never stops for a request. - One email, two accounts. A person with accounts in two brand orgs collides in
id. Merging moves memberships and money and is never automatic. admin/means SuperAdmin. Today every org and membership row, most applications and every signing certificate are filed underadmin, so an org id prints like a SuperAdmin account and a reader that trusts the prefix trusts the wrong thing. Step 8 empties the namespace of everything but SuperAdmin accounts, and I14 keeps it that way.- No cross-contamination of admin. An org's admins are rows in that org's own namespace. An account-level flag cannot say which org it means, so every reader of one either widened it to every org the account touches or narrowed it to the org the account lives in — both wrong once a person belongs to two orgs. I15–I17 make the flag unrepresentable.
References
- HIP-0026 — Identity & Access Management Standard
- HIP-0111 — IAM Authentication Standard
- HIP-0118 — SuperAdmin & Tenant Isolation Model
- HIP-0519 — One Identity Boundary
- HIP-0521 — Org Hierarchy
- HIP-1045 — Orgs
Copyright
Released under the MIT License.