HIP-1327: Public Channels — Finding a Room in Another Org
Abstract
A room is already public or private (HIP-0523), and public is the default. But public means anyone in the org — the list a person can browse stops at their own tenant. This HIP specifies how a room becomes findable from outside the org that owns it, and what it takes to join one.
It adds one platform-scoped directory and two addresses. It does not add a second room store, a second message path, or federation between deployments. A room joined from another org is the same room in the same store; what changes is who its roster admits.
Motivation
The ask is ordinary and the product does not answer it: everyone should be able to see and join the Hanzo channels, and browse orgs that have public ones. An open-source project's channel is worth nothing if only the company that owns the org can find it.
What blocks it is not policy. listRooms (apps/team/room.go:208) resolves the
caller's org, asks SpacesForOrg for that org's spaces, and reads each with
byClasses(org, space, …). That last call is s.db(org, space)
(apps/team/store.go:198) — the org and space select a distinct database.
Tenancy here is physical, which is the estate's rule and not this subsystem's
quirk: a distinct org is a distinct file.
So "list public rooms across orgs" cannot be a predicate added to that handler. Answering it there would mean opening every tenant's database on an unauthenticated person's behalf, which is the one thing the storage model exists to prevent. The feature needs somewhere else to look.
Specification
1. The directory
A single platform-scoped index, outside every org's store, holding one row per room that is public:
org the owning org
space the space within it
room the room id, as the owning store knows it
name what a person sees
topic one line, when the room has one
members a count, never a roster
updated when this row was last written
It holds no message, no member identity, and no private room. A row is the minimum needed to render a result and address the room it names; anything more would be a second copy of the room, and HIP-0523 has one store for a reason.
The directory is a projection, never a source. If it disagrees with the owning org's store, the store is right and the row is stale.
2. When a row is written
The owning org writes its own rows. Four events, all of them already writes the
team app performs:
| event | effect |
|---|---|
| a public room is opened | insert |
| a room is renamed or its topic set | update |
| a room turns private | delete |
| a room is archived | delete |
A room that turns private must leave the directory in the same write that turns it, not on a later sweep. The window between the two is a window in which a private room is advertised.
3. Finding
GET /v1/team/public?q=&org=&limit=&cursor=
Answers rows from the directory, newest-updated first. q matches name and
topic; org narrows to one. It is readable by any authenticated principal —
the rows carry nothing an org has not published, and a directory only its own
org can read is not a directory.
It is NOT part of GET /v1/team/rooms. That address answers the caller's
rooms and its meaning must not change: a client that renders it as a sidebar
would start listing strangers' channels in a person's own list.
4. Joining
POST /v1/team/rooms/{id}/join
The one write that crosses a tenant boundary, and the whole of the new authorization. It admits the calling principal to the roster of a room their org does not own, and it is refused unless every one of these holds:
- the room is in the directory — that is what "public" means here, and reading the owning store to decide would be the cross-tenant read this HIP avoids;
- the room is still public in the owning store, re-read at the moment of the write, because a directory row can be stale and a stale row must not admit anybody;
- the caller is an authenticated principal with a home org — a machine token with no org joins nothing;
- the owning org has not barred the caller's org.
A join writes a member into the owning org's store. It does not copy the room, and it does not give the joiner's org a room of its own.
What this costs, measured
Admitting a stranger is not a matter of appending to members. Every read path
resolves the space through identity.admit (apps/team/account.go:1267), which
calls SpaceByUUID(ctx, cl.org, wsUUID) — the space is looked up scoped to the
CALLER'S org, so a space another org owns does not resolve for a stranger at
all, and the call returns errNoSpace before membership is ever consulted.
AccountForSubject (account_store.go:390) is the same shape one layer down: it
asks IAM whether the subject may act in that org and answers false for
anybody who may not.
So room membership across orgs cannot be added as a row. It requires a room-scope
that exists independently of org membership — a caller admitted to one document
in a tenant they are not a member of — and that is a change to what admit
means, which is the tenant boundary itself. It is an architecture decision and
this HIP does not smuggle it in as an implementation detail. Two shapes are
worth weighing, and neither should be chosen without the owner:
- A guest membership row in the owning org, carrying the room it is scoped
to.
admitgains a second question rather than a looser first one; the boundary stays a boundary and a guest reaches exactly one document. - Grant the joiner IAM membership of the owning org. Simpler to write and much larger than it looks: it makes a channel-joiner a member of the company, which is not what joining a channel means.
The directory and its read (§1–§3) do not wait on this, and are shipped.
5. What a joined member is
A member from another org is a member: they read the room's messages and write messages to it. They are NOT an administrator of it. Specifically, they may not rename it, bind it, make it private, archive it, or admit anybody else. Those verbs stay with the owning org, because the room is the owning org's document and its lifecycle is that org's to state.
Leaving is DELETE on the same address, and removes exactly the caller.
6. What this does not specify
- Federation between deployments. Everything here is within one estate. Two estates sharing a channel is a different problem and needs a different HIP.
- A public DM.
teamRoomNewcarries nodirectfield (apps/team/room.go:432), so a direct message is not opened by that route and is not made public by this one. - Discovery of orgs.
org=filters the directory by a name the caller already has. An index of orgs isiam's to publish, notteam's.
Rationale
Why a directory rather than a query. The alternative is a handler that opens every org's database and filters. That is not a bigger version of the current read; it is the opposite of it. The physical partition is the isolation, so a reader that spans partitions has no isolation left to rely on — one bug in the predicate leaks a private room, and nothing structural stops it. A projection holding only published fields cannot leak what it never held.
Why the join re-reads the owning store. A directory is eventually consistent by construction. Trusting it for the admission decision means a room that turned private a second ago still admits strangers. The row decides what is findable; the store decides what is joinable. Two questions, two sources, and the stricter one wins.
Why joining does not copy the room. A copy is a second store, and then two rooms drift and nobody can say which one a message landed in — the same argument HIP-0523 makes for having one store at all.
Why a member count and not a roster. "How busy is this channel" is what a person choosing between rooms is asking. Who is in it is that org's to disclose, and a roster in a public index is a membership list published without consent.
Security Considerations
The directory is the disclosure boundary. Every field in it is world-readable to authenticated principals; nothing may be added to it that an org has not chosen to publish by making a room public. Adding a field to that row is a disclosure decision, not a schema change.
A stale row must never admit. §4.2 is the load-bearing clause. Without the re-read, the window between "turned private" and "row deleted" is a window in which anybody can join a private room, and directory writes are exactly the kind of thing that fails quietly.
A join is an audited privileged write. It adds a principal from outside the tenant to a document inside it. It belongs in the audit trail with the joiner, the room, and both orgs named, for the same reason every other cross-boundary write does.
Barring is per-org and must be checkable without the joiner's cooperation. §4.4 is what lets an org that publishes a channel stop a specific org from entering it. It is a property of the owning org, read on the join path.
Measured state, 2026-09-01
Against apps/team at 84c74fee81, and probed on production and a local cloud
with /v1/bogus-control-xyz (404) as the control:
| thing | state |
|---|---|
GET /v1/team/rooms | shipped — room.go:121, 403 unauthenticated |
POST /v1/team/rooms | shipped — room.go:122, opens a room, public by default |
PUT /v1/team/rooms/{id} | shipped — room.go:123, states intent |
GET/POST /rooms/{id}/messages | shipped — message.go:103-104 |
private on a room | shipped — teamRoomNew, room.go:432 |
| org-scoped listing | shipped — room.go:208, via s.db(org, space) |
| the directory | shipped — apps/team/public.go, system namespace |
GET /v1/team/public | shipped — typed, x-tool, published in the subset |
| publish on open | shipped — room.go, public rooms only, best-effort |
POST /v1/team/rooms/{id}/join | absent — blocked on §4's decision |
A local cloud's /v1/openapi.json listed GET alone on /v1/team/rooms while
the route answered 403 to a POST. The document lagged the binary; the route
is the measurement. Probe the address, not the spec.
References
- HIP-0139 — A Capability Answers At Its Own Name
- HIP-0523 — Rooms — One Store, Two Views, Many Bridges
- HIP-1048 — Team
- HIP-1066 — Channels — One Inbox
Copyright
Released under the same terms as the rest of the HIPs repository.