9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1143 lines
60 KiB
Plaintext
1143 lines
60 KiB
Plaintext
-------------------------- MODULE MultiTenantRelay --------------------------
|
|
(***************************************************************************)
|
|
(* Formal model of Buzz's proposed multi-tenant relay/database isolation. *)
|
|
(* *)
|
|
(* This is the TLA+ half of the multi-tenant relay proof. It models N *)
|
|
(* stateless relay workers over one shared Postgres database containing a *)
|
|
(* community_id-keyed canonical message log, tenant-scoped control-plane *)
|
|
(* state, and rebuildable projections. *)
|
|
(* *)
|
|
(* The master proof obligation is NOT merely "no row with the wrong *)
|
|
(* community_id is returned." The theorem contract is non-interference *)
|
|
(* encoded as a label/taint invariant: every state element and every *)
|
|
(* observation carries the community labels that influence it; no value *)
|
|
(* labeled outside a connection's resolved community may flow into that *)
|
|
(* connection's typed observational interface. *)
|
|
(* *)
|
|
(* C1 carve-out (not modeled as a security theorem): bandwidth-limited *)
|
|
(* physical resource timing channels such as buffer cache, autovacuum, *)
|
|
(* planner stats, hot partition tails, and connection-pool latency. *)
|
|
(* *)
|
|
(* C2 channels modeled here and closed by invariants: *)
|
|
(* - event-id existence oracle: write conflict checks are scoped by *)
|
|
(* (community, id), not global id; cross-community same-id writes do *)
|
|
(* not suppress each other. A_HASH covers adversarial preimage probing. *)
|
|
(* - constraint/error surface: the relay emits only a fixed sanitized *)
|
|
(* error alphabet; sanitized error observations are relay-static and *)
|
|
(* carry no tenant label. *)
|
|
(* - projection rebuild: rebuild touches all communities internally but *)
|
|
(* emits no tenant observation; tenant reads see only own projection *)
|
|
(* rows (or a subset/none during rebuild). *)
|
|
(* *)
|
|
(* Source grounding from today's Buzz: *)
|
|
(* - migrations/0001_initial_schema.sql: events, channels, *)
|
|
(* channel_members, event_mentions, thread_metadata, reactions, *)
|
|
(* workflows, api_tokens, relay_members. *)
|
|
(* - crates/buzz-db/src/event.rs: EventQuery has channel_id/channel_ids *)
|
|
(* but no community_id; inserts use ON CONFLICT DO NOTHING. *)
|
|
(* - crates/buzz-db/src/channel.rs: get_accessible_channel_ids currently *)
|
|
(* unions all open channels in the DB; that unscoped variant is the *)
|
|
(* explicit I1 mutation. *)
|
|
(* - crates/buzz-relay/src/state.rs: process-global AppState/caches today. *)
|
|
(* *)
|
|
(* Mutation tests to keep non-vacuous: *)
|
|
(* M1: ReadScoped uses UnscopedAccessible(actor) instead of *)
|
|
(* ScopedAccessible(community, actor) -> Inv_NonInterference breaks. *)
|
|
(* M2: WriteInsert/AuthCheck use claimedCommunity/h tag instead of *)
|
|
(* ChannelCommunity(channel) -> resolution-fence invariants break. *)
|
|
(* M3: WriteDuplicate conflict on id only (GlobalConflictRows + guard *)
|
|
(* conflicts # {}), not (community,id) -> cross-community suppression *)
|
|
(* labels a B write result with A and Inv_NonInterference breaks. *)
|
|
(* Confirmed red: Safety violated at depth 3 (see GlobalConflictRows). *)
|
|
(* M4: ReadForgotPredicateWithRLS returns candidates without RLSRows -> *)
|
|
(* Inv_NonInterference breaks. *)
|
|
(* M5: Projection rebuild emits an observation or projection reads ignore *)
|
|
(* row labels -> Inv_NonInterference breaks. *)
|
|
(* M6: Error observation carries raw/high labels or a value outside the *)
|
|
(* sanitized alphabet -> Inv_SanitizedErrors/NI breaks. *)
|
|
(* M7: Direct ids lookup ignores ctx and resolves scope from the row/global *)
|
|
(* id index -> a B-scoped observation can carry an A-labeled row. *)
|
|
(* M8: WriteInsert/AuthCheck/WriteDuplicate drop the host/channel agreement *)
|
|
(* fence (accept/report when HostCommunity[host] # ChannelCommunity(ch)) ->*)
|
|
(* an A-host op (insert, authz, OR duplicate-probe) on a B-channel is *)
|
|
(* accepted/allowed/reported -> Inv_HostBindingFence breaks (an accepted *)
|
|
(* write or recorded duplicate carries a host whose mapping # its stamp). *)
|
|
(* Confirmed red: Inv_HostBindingFence violated by a 2-state trace *)
|
|
(* (Init -> WriteInsert) for the insert path and a 3-state trace *)
|
|
(* (Init -> WriteInsert -> WriteDuplicate) for the duplicate path. (Trace *)
|
|
(* length, not TLC's run-dependent "depth of complete graph search" line.) *)
|
|
(* M9: NIP-43 community admission keyed globally (admit-if-member-of-ANY- *)
|
|
(* community) instead of by (community, actor) -> Inv_AdmissionFence *)
|
|
(* breaks when a B-admitted actor joins or channel-less-reads in A. *)
|
|
(* Confirmed red: 5-state membership trace (Init -> WriteInsert -> *)
|
|
(* WriteInsert -> AdmitMember(commA, alice) -> AddMembership(commB)) and *)
|
|
(* 4-state channel-less read trace (Init -> WriteInsert -> *)
|
|
(* AdmitMember(commB, alice) -> ReadMessageRows(commA, NoChannel, hostA)). *)
|
|
(* M10: open-community AUTH admission stamps a default/claimed community *)
|
|
(* instead of HostCommunity(host) -> Inv_AdmissionFence catches an *)
|
|
(* authRegistration whose host maps elsewhere. Confirmed red: 2-state *)
|
|
(* trace (Init -> AuthenticateOpenCommunity(hostB stamps commA)). *)
|
|
(* M11: channel creation stamps a default/claimed community instead of *)
|
|
(* HostCommunity(host) -> Inv_HostBindingFence / *)
|
|
(* Inv_ChannelCommunityImmutable catches the fresh channel's bad owner. *)
|
|
(* Confirmed red: 2-state trace (Init -> CreateChannel(hostB stamps commA)).*)
|
|
(* M12: no-#h host feed admission is relay-global (GloballyAdmitted(actor)) *)
|
|
(* instead of by host community -> Inv_AdmissionFence catches a feed *)
|
|
(* read in A by an actor admitted only in B. Confirmed red: 3-state *)
|
|
(* trace (Init -> AdmitMember(commB, alice) -> ReadHostFeedRows(hostA)). *)
|
|
(* M13: no-#h #e-only aux admission is relay-global (GloballyAdmitted(actor)) *)
|
|
(* instead of by host community -> Inv_AdmissionFence catches an aux *)
|
|
(* read in A by an actor admitted only in B. Confirmed red: 3-state *)
|
|
(* trace (Init -> AdmitMember(commB, alice) -> ReadHostAuxRows(hostA)). *)
|
|
(***************************************************************************)
|
|
EXTENDS FiniteSets, Naturals, TLC
|
|
|
|
CONSTANTS
|
|
Communities, \* finite set of community ids
|
|
Channels, \* finite set of channel ids
|
|
Hosts, \* finite set of connection hostnames/URLs (the community selector)
|
|
Actors, \* finite set of pubkeys/actors
|
|
Workers, \* finite set of relay worker/process ids
|
|
MsgIds, \* finite set of event ids (model bound)
|
|
AuditVals, \* finite set of audit head values (model bound)
|
|
CommA, \* model value: first community in TLC config
|
|
CommB, \* model value: second community in TLC config
|
|
ChanA1, \* model value: community-A channel in TLC config
|
|
ChanA2, \* model value: community-A channel in TLC config
|
|
ChanB1, \* model value: community-B channel in TLC config
|
|
ChanB2, \* model value: community-B channel in TLC config
|
|
ChanFresh, \* model value: initially-unregistered channel in TLC config
|
|
HostA, \* model value: host bound to community A
|
|
HostB, \* model value: host bound to community B
|
|
HostBad, \* model value: unmapped host (resolves to NoCommunity)
|
|
NoChannel, \* model value: sentinel channel for channel-less events
|
|
NoCommunity, \* model value: sentinel for an unmapped host (fail-closed)
|
|
OpenCommunities, \* communities with no NIP-43 member pubkey allowlist
|
|
SanitizedErrors \* fixed WebSocket-reachable sanitized error alphabet
|
|
|
|
ObsKinds == {"ResultRows", "WriteResult", "SanitizedError", "AuditHead", "AuthVerdict"}
|
|
MaxObservations == 2
|
|
WriteResults == {"Inserted", "Duplicate", "None"}
|
|
AuthVerdicts == {"Allow", "Deny", "None"}
|
|
NoError == "NoError"
|
|
NoAudit == "NoAudit"
|
|
|
|
InitialChannelOwners == [ch \in Channels |->
|
|
CASE ch \in {ChanA1, ChanA2} -> CommA
|
|
[] ch \in {ChanB1, ChanB2} -> CommB
|
|
[] OTHER -> NoCommunity]
|
|
|
|
\* resolve_host (P-RESOLVE-HOST): the connection's host is authoritative for the
|
|
\* community, exactly as a relay URL is authoritative for the relay today, lifted
|
|
\* one level up to community. A host maps to exactly one community, or to the
|
|
\* NoCommunity sentinel (an unmapped/unknown host) -> the connection fails closed
|
|
\* and no channel-less write may derive a community from it. This is the upstream
|
|
\* of ResolveTenant: ctx.community is *derived* from the host, never free-chosen.
|
|
HostCommunity == [h \in Hosts |->
|
|
CASE h = HostA -> CommA
|
|
[] h = HostB -> CommB
|
|
[] OTHER -> NoCommunity]
|
|
|
|
\* Channel-or-sentinel: data rows and write/observation records may be channel-
|
|
\* less (channel = NoChannel), in which case the community comes from the host.
|
|
ChannelsExt == Channels \cup {NoChannel}
|
|
|
|
\* Every accepted write and recorded duplicate stamps its real originating host
|
|
\* (host \in Hosts): channel-less writes resolve the community from the host, and
|
|
\* channel-bearing writes/duplicates stamp the host on agreement (fail-closed on
|
|
\* disagreement, so no record carries a host that does not map to its community).
|
|
\* This lets Inv_HostBindingFence check that every record's stored community equals
|
|
\* its host's mapping -- i.e. the binding is enforced, not defaulted.
|
|
|
|
Symmetry ==
|
|
Permutations(Actors) \cup
|
|
Permutations(Workers) \cup
|
|
Permutations(MsgIds) \cup
|
|
Permutations(AuditVals)
|
|
|
|
VARIABLES
|
|
messages, \* set of canonical message rows (source="message")
|
|
projections, \* set of rebuildable projection rows (source="projection")
|
|
memberships, \* tenant-scoped active channel membership rows
|
|
admittedMembers, \* NIP-43 community member-npub allowlist rows
|
|
channelLessReads, \* successful channel-less read admissions (current capabilities)
|
|
authRegistrations, \* open-community AUTH auto-registration witnesses
|
|
feedReads, \* no-#h kinds-only feed read witnesses
|
|
auxReads, \* no-#h #e-only aux read witnesses
|
|
openChannels, \* set of open/public channel ids
|
|
auditHeads, \* function: community -> current audit head
|
|
observations, \* typed outputs visible to tenant-scoped clients
|
|
acceptedWrites, \* write requests that inserted a new message row
|
|
duplicateWrites, \* write requests that no-op'd on scoped conflict
|
|
createdChannels, \* channel-create witnesses stamped from HostCommunity[host]
|
|
queryFaults \* fail-closed query-layer faults (e.g. no TenantContext)
|
|
|
|
vars == <<messages, projections, memberships, admittedMembers,
|
|
channelLessReads, authRegistrations, feedReads, auxReads, openChannels,
|
|
auditHeads, observations, acceptedWrites, duplicateWrites, createdChannels,
|
|
queryFaults>>
|
|
|
|
ChannelCommunity(ch) ==
|
|
IF InitialChannelOwners[ch] \in Communities THEN InitialChannelOwners[ch]
|
|
ELSE IF \E created \in createdChannels : created.channel = ch
|
|
THEN (CHOOSE created \in createdChannels : created.channel = ch).community
|
|
ELSE NoCommunity
|
|
|
|
ChannelRegistered(ch) == ChannelCommunity(ch) \in Communities
|
|
|
|
\* ResolveTenant: the single resolver generalizing P-RESOLVE / L1 over both
|
|
\* channel-bearing and channel-less events. For a channel-bearing op the
|
|
\* community is the server-owned channel mapping (and an h-cross-check requires it
|
|
\* to agree with the host community, see WriteInsert). For a channel-less op
|
|
\* (channel = NoChannel) the community is the host-derived community.
|
|
ResolveTenant(host, ch) ==
|
|
IF ch = NoChannel THEN HostCommunity[host] ELSE ChannelCommunity(ch)
|
|
|
|
DataRows == [
|
|
id : MsgIds,
|
|
community : Communities,
|
|
channel : ChannelsExt,
|
|
author : Actors,
|
|
source : {"message", "projection"}
|
|
]
|
|
|
|
MessageRows == {r \in DataRows : r.source = "message"}
|
|
ProjectionRows == {r \in DataRows : r.source = "projection"}
|
|
|
|
MembershipRows == [
|
|
community : Communities,
|
|
channel : Channels,
|
|
actor : Actors
|
|
]
|
|
|
|
AdmissionRows == [
|
|
community : Communities,
|
|
actor : Actors
|
|
]
|
|
|
|
ChannelLessReadRows == [
|
|
worker : Workers,
|
|
community : Communities,
|
|
host : Hosts,
|
|
actor : Actors
|
|
]
|
|
|
|
AuthRegistrationRows == [
|
|
worker : Workers,
|
|
community : Communities,
|
|
host : Hosts,
|
|
actor : Actors
|
|
]
|
|
|
|
FeedReadRows == [
|
|
worker : Workers,
|
|
community : Communities,
|
|
host : Hosts,
|
|
actor : Actors
|
|
]
|
|
|
|
AuxReadRows == [
|
|
worker : Workers,
|
|
community : Communities,
|
|
host : Hosts,
|
|
actor : Actors,
|
|
id : MsgIds
|
|
]
|
|
|
|
CreatedChannelRows == [
|
|
worker : Workers,
|
|
community : Communities,
|
|
channel : Channels,
|
|
host : Hosts,
|
|
actor : Actors
|
|
]
|
|
|
|
AcceptedWriteRows == [
|
|
worker : Workers,
|
|
id : MsgIds,
|
|
community : Communities,
|
|
channel : ChannelsExt,
|
|
host : Hosts,
|
|
author : Actors,
|
|
claimedCommunity : Communities
|
|
]
|
|
|
|
DuplicateWriteRows == [
|
|
worker : Workers,
|
|
id : MsgIds,
|
|
community : Communities,
|
|
channel : ChannelsExt,
|
|
host : Hosts,
|
|
author : Actors,
|
|
claimedCommunity : Communities
|
|
]
|
|
|
|
Observations == [
|
|
worker : Workers,
|
|
actor : Actors,
|
|
community : Communities, \* resolved/request TenantContext community
|
|
channel : ChannelsExt, \* target channel; NoChannel for channel-less ops
|
|
kind : ObsKinds,
|
|
labels : SUBSET Communities, \* taint labels influencing this observation
|
|
rows : SUBSET DataRows, \* row/projection dependencies, if any
|
|
error : SanitizedErrors \cup {NoError},
|
|
result : WriteResults,
|
|
verdict : AuthVerdicts,
|
|
audit : AuditVals \cup {NoAudit}
|
|
]
|
|
|
|
QueryFaultRows == [
|
|
worker : Workers,
|
|
actor : Actors,
|
|
community : Communities,
|
|
reason : {"missing_tenant_context"}
|
|
]
|
|
|
|
MessageRow(id, c, ch, a) ==
|
|
[id |-> id, community |-> c, channel |-> ch, author |-> a, source |-> "message"]
|
|
|
|
ProjectionRow(m) ==
|
|
[id |-> m.id, community |-> m.community, channel |-> m.channel,
|
|
author |-> m.author, source |-> "projection"]
|
|
|
|
RowLabels(rows) == {r.community : r \in rows}
|
|
|
|
MessageKeys == {[community |-> m.community, id |-> m.id] : m \in messages}
|
|
|
|
ScopedConflictRows(c, id) == {m \in messages : m.community = c /\ m.id = id}
|
|
\* Intentionally-bad global conflict set for mutation M3 (the missing-
|
|
\* community_id-in-the-unique-index footgun, i.e. UNIQUE(id) instead of
|
|
\* UNIQUE(community_id,...,id)). To run M3: in WriteDuplicate substitute
|
|
\* conflicts == ScopedConflictRows(real, id)
|
|
\* and change the duplicate guard from key \in MessageKeys to conflicts # {}
|
|
\* (a global index fires the dup branch whenever the id exists in ANY community).
|
|
\* Confirmed red: Invariant Safety violated at depth 3, with a B-scoped
|
|
\* WriteResult observation carrying labels |-> {commA} (the C2.1 existence-oracle
|
|
\* leak). Closure is A-RLS-5 (composite index), with A_HASH as supporting axiom.
|
|
GlobalConflictRows(id) == {m \in messages : m.id = id}
|
|
|
|
DerivedProjectionRows == {ProjectionRow(m) : m \in messages}
|
|
|
|
TypeOK ==
|
|
/\ Communities # {}
|
|
/\ Channels # {}
|
|
/\ Actors # {}
|
|
/\ Workers # {}
|
|
/\ MsgIds # {}
|
|
/\ AuditVals # {}
|
|
/\ InitialChannelOwners \in [Channels -> Communities \cup {NoCommunity}]
|
|
/\ HostCommunity \in [Hosts -> Communities \cup {NoCommunity}]
|
|
/\ OpenCommunities \subseteq Communities
|
|
/\ CommA \in Communities
|
|
/\ CommB \in Communities
|
|
/\ CommA # CommB
|
|
/\ {ChanA1, ChanA2, ChanB1, ChanB2, ChanFresh} \subseteq Channels
|
|
/\ Cardinality({ChanA1, ChanA2, ChanB1, ChanB2, ChanFresh}) = 5
|
|
/\ InitialChannelOwners[ChanFresh] = NoCommunity
|
|
/\ ChanA1 # ChanB1
|
|
/\ {HostA, HostB, HostBad} \subseteq Hosts
|
|
/\ NoChannel \notin Channels
|
|
/\ NoCommunity \notin Communities
|
|
/\ HostCommunity[HostBad] = NoCommunity
|
|
/\ SanitizedErrors # {}
|
|
/\ NoError \notin SanitizedErrors
|
|
/\ NoAudit \notin AuditVals
|
|
/\ messages \subseteq MessageRows
|
|
/\ projections \subseteq ProjectionRows
|
|
/\ projections \subseteq DerivedProjectionRows
|
|
/\ memberships \subseteq MembershipRows
|
|
/\ admittedMembers \subseteq AdmissionRows
|
|
/\ channelLessReads \subseteq ChannelLessReadRows
|
|
/\ authRegistrations \subseteq AuthRegistrationRows
|
|
/\ feedReads \subseteq FeedReadRows
|
|
/\ auxReads \subseteq AuxReadRows
|
|
/\ openChannels \subseteq Channels
|
|
/\ openChannels \subseteq {ch \in Channels : ChannelRegistered(ch)}
|
|
/\ auditHeads \in [Communities -> AuditVals]
|
|
/\ observations \subseteq Observations
|
|
/\ acceptedWrites \subseteq AcceptedWriteRows
|
|
/\ duplicateWrites \subseteq DuplicateWriteRows
|
|
/\ createdChannels \subseteq CreatedChannelRows
|
|
/\ queryFaults \subseteq QueryFaultRows
|
|
\* Tenant-scoped control plane: a membership row's community agrees with
|
|
\* the server-owned channel -> community mapping. Memberships are always
|
|
\* channel-scoped (no channel-less memberships).
|
|
/\ \A m \in memberships : /\ ChannelRegistered(m.channel)
|
|
/\ m.community = ChannelCommunity(m.channel)
|
|
\* Message/projection rows are stamped with the resolved community: the
|
|
\* server-owned channel mapping for channel-bearing rows, and a real
|
|
\* (never-sentinel) community for channel-less rows (host-resolved at write).
|
|
/\ \A m \in messages :
|
|
IF m.channel = NoChannel THEN m.community \in Communities
|
|
ELSE /\ ChannelRegistered(m.channel)
|
|
/\ m.community = ChannelCommunity(m.channel)
|
|
/\ \A p \in projections :
|
|
IF p.channel = NoChannel THEN p.community \in Communities
|
|
ELSE /\ ChannelRegistered(p.channel)
|
|
/\ p.community = ChannelCommunity(p.channel)
|
|
|
|
Init ==
|
|
/\ messages = {}
|
|
/\ projections = {}
|
|
/\ memberships = {}
|
|
/\ admittedMembers = {}
|
|
/\ channelLessReads = {}
|
|
/\ authRegistrations = {}
|
|
/\ feedReads = {}
|
|
/\ auxReads = {}
|
|
/\ openChannels = {}
|
|
/\ auditHeads \in [Communities -> AuditVals]
|
|
/\ observations = {}
|
|
/\ acceptedWrites = {}
|
|
/\ duplicateWrites = {}
|
|
/\ createdChannels = {}
|
|
/\ queryFaults = {}
|
|
|
|
IsAdmitted(community, actor) ==
|
|
[community |-> community, actor |-> actor] \in admittedMembers
|
|
|
|
\* Intentionally-bad admission helper for mutation M9: treats the NIP-43 member
|
|
\* allowlist as relay-global by dropping community from the key. Substitute this
|
|
\* for IsAdmitted in AddMembership / channel-less reads to reproduce M9.
|
|
GloballyAdmitted(actor) ==
|
|
\E c \in Communities : [community |-> c, actor |-> actor] \in admittedMembers
|
|
|
|
\* Open-community AUTH mutation M10 helper: stamp every open AUTH registration
|
|
\* into CommA, ignoring the authoritative host. To reproduce M10, substitute
|
|
\* DefaultOpenAuthCommunity(host) for HostCommunity[host] when building row/reg
|
|
\* in AuthenticateOpenCommunity; HostB then records a commA registration and
|
|
\* Inv_AdmissionFence fails in a 2-state trace.
|
|
DefaultOpenAuthCommunity(host) == CommA
|
|
|
|
\* Channel-creation mutation M11 helper: stamp every fresh channel into CommA,
|
|
\* ignoring the authoritative host. To reproduce M11, substitute
|
|
\* DefaultCreatedChannelCommunity(host) for HostCommunity[host] in CreateChannel;
|
|
\* HostB then creates a commA-owned channel and the host/immutability fences fail.
|
|
DefaultCreatedChannelCommunity(host) == CommA
|
|
|
|
ScopedAccessible(community, actor) ==
|
|
{ch \in Channels :
|
|
/\ ChannelCommunity(ch) = community
|
|
/\ (ch \in openChannels \/
|
|
[community |-> community, channel |-> ch, actor |-> actor]
|
|
\in memberships)}
|
|
|
|
\* Intentionally-bad operator matching today's shared-DB landmine: open channels
|
|
\* are global, not scoped by TenantContext. The correct spec does not call this;
|
|
\* substitute it into ReadScoped/ReadProjectionRows for mutation M1.
|
|
UnscopedAccessible(actor) ==
|
|
{ch \in Channels :
|
|
ch \in openChannels \/
|
|
\E c \in Communities :
|
|
[community |-> c, channel |-> ch, actor |-> actor] \in memberships}
|
|
|
|
VisibleMessageRows(community, actor, targetChannel) ==
|
|
{m \in messages :
|
|
/\ m.community = community
|
|
/\ IF targetChannel = NoChannel
|
|
THEN /\ m.channel = NoChannel
|
|
/\ IsAdmitted(community, actor)
|
|
ELSE m.channel \in ScopedAccessible(community, actor)}
|
|
|
|
VisibleProjectionRows(community, actor, targetChannel) ==
|
|
{p \in projections :
|
|
/\ p.community = community
|
|
/\ IF targetChannel = NoChannel
|
|
THEN /\ p.channel = NoChannel
|
|
/\ IsAdmitted(community, actor)
|
|
ELSE p.channel \in ScopedAccessible(community, actor)}
|
|
|
|
VisibleDirectIdRows(community, actor, id, targetChannel) ==
|
|
{m \in messages :
|
|
/\ m.id = id
|
|
/\ m.community = community
|
|
/\ IF targetChannel = NoChannel
|
|
THEN /\ m.channel = NoChannel
|
|
/\ IsAdmitted(community, actor)
|
|
ELSE m.channel \in ScopedAccessible(community, actor)}
|
|
|
|
VisibleHostFeedRows(community, actor) ==
|
|
{m \in messages :
|
|
/\ m.community = community
|
|
/\ IsAdmitted(community, actor)
|
|
/\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))}
|
|
|
|
\* Intentionally-bad host feed helper for mutation M12: answers from the host
|
|
\* community but treats admission as relay-global. To reproduce the red mutation,
|
|
\* change ReadHostFeedRows' admission guard from IsAdmitted(c, a) to
|
|
\* GloballyAdmitted(a) and optionally use this row helper; after an actor is
|
|
\* admitted only in B, a HostA feed read is recorded for commA and
|
|
\* Inv_AdmissionFence fails.
|
|
VisibleHostFeedRows_GlobalAdmission(community, actor) ==
|
|
{m \in messages :
|
|
/\ m.community = community
|
|
/\ GloballyAdmitted(actor)
|
|
/\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))}
|
|
|
|
VisibleHostAuxRows(community, actor, id) ==
|
|
{m \in messages :
|
|
/\ m.id = id
|
|
/\ m.community = community
|
|
/\ IsAdmitted(community, actor)
|
|
/\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))}
|
|
|
|
\* Intentionally-bad host aux helper for mutation M13: answers from the host
|
|
\* community but treats admission as relay-global. To reproduce the red mutation,
|
|
\* change ReadHostAuxRows' admission guard from IsAdmitted(c, a) to
|
|
\* GloballyAdmitted(a) and optionally use this row helper; after an actor is
|
|
\* admitted only in B, a HostA aux read is recorded for commA and
|
|
\* Inv_AdmissionFence fails.
|
|
VisibleHostAuxRows_GlobalAdmission(community, actor, id) ==
|
|
{m \in messages :
|
|
/\ m.id = id
|
|
/\ m.community = community
|
|
/\ GloballyAdmitted(actor)
|
|
/\ (m.channel = NoChannel \/ m.channel \in ScopedAccessible(community, actor))}
|
|
|
|
\* Intentionally-bad direct lookup mutation: answer by global id first and trust
|
|
\* the row's own community as scope. Substitute this into ReadByIdRows for M7.
|
|
UnscopedDirectIdRows(actor, id) ==
|
|
{m \in messages :
|
|
/\ m.id = id
|
|
/\ m.channel \in UnscopedAccessible(actor)}
|
|
|
|
RLSRows(community, rows) == {r \in rows : r.community = community}
|
|
|
|
\* Channel-bearing write. The community is resolved server-side from the h tag
|
|
\* (real == ChannelCommunity(ch)), never the claimed community. But the host is
|
|
\* *also* authoritative: an A-host connection presenting a B-channel event is a
|
|
\* confused deputy on the host axis (URL-is-community is the admission boundary),
|
|
\* so the op fails closed unless HostCommunity[host] = ChannelCommunity(ch).
|
|
\* Disagreement (incl. an unmapped HostBad whose mapping is NoCommunity, which can
|
|
\* never equal a real channel community) writes nothing, emits a sanitized error,
|
|
\* and records a query fault. Never acts as the channel's community.
|
|
WriteInsert(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E id \in MsgIds, ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities :
|
|
LET real == ChannelCommunity(ch)
|
|
IN
|
|
IF ~(real \in Communities) \/ HostCommunity[host] # real
|
|
THEN \* fail-closed: unknown channel or host/channel disagreement (A-host + B-channel).
|
|
LET obs == [worker |-> w, actor |-> a, community |-> claimed,
|
|
channel |-> ch, kind |-> "SanitizedError",
|
|
labels |-> {}, rows |-> {}, error |-> "restricted",
|
|
result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
fault == [worker |-> w, actor |-> a, community |-> claimed,
|
|
reason |-> "missing_tenant_context"]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ queryFaults' = queryFaults \cup {fault}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
channelLessReads, authRegistrations, feedReads, auxReads,
|
|
openChannels, auditHeads, acceptedWrites, duplicateWrites,
|
|
createdChannels>>
|
|
ELSE \* host agrees with the channel mapping: accept, stamp the host.
|
|
LET key == [community |-> real, id |-> id]
|
|
row == MessageRow(id, real, ch, a)
|
|
obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch,
|
|
kind |-> "WriteResult", labels |-> {real}, rows |-> {row},
|
|
error |-> NoError, result |-> "Inserted", verdict |-> "None", audit |-> NoAudit]
|
|
wr == [worker |-> w, id |-> id, community |-> real,
|
|
channel |-> ch, host |-> host, author |-> a, claimedCommunity |-> claimed]
|
|
IN
|
|
/\ key \notin MessageKeys
|
|
/\ messages' = messages \cup {row}
|
|
/\ observations' = observations \cup {obs}
|
|
/\ acceptedWrites' = acceptedWrites \cup {wr}
|
|
/\ UNCHANGED <<projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* Channel-less write (kind:0 profiles, 1059 DMs, 30023/30174/30315/30078, lists).
|
|
\* There is no h tag to resolve, so the community is derived from the connection's
|
|
\* host (P-RESOLVE-HOST / ResolveTenant): the row is stamped channel = NoChannel,
|
|
\* community = HostCommunity[host]. The claimed community is adversary-controlled
|
|
\* and ignored -- host wins (the confused-deputy fence, lifted to the host).
|
|
\* An unmapped host (HostCommunity[host] = NoCommunity) fails closed: no row is
|
|
\* written, the connection sees only a sanitized error, and a query fault is
|
|
\* recorded. Never a default tenant.
|
|
WriteInsertGlobal(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E id \in MsgIds, host \in Hosts, a \in Actors, claimed \in Communities :
|
|
LET resolved == HostCommunity[host]
|
|
IN
|
|
IF resolved = NoCommunity \/ ~IsAdmitted(resolved, a)
|
|
THEN \* fail-closed: unmapped/unknown host or community admission miss.
|
|
LET obs == [worker |-> w, actor |-> a, community |-> claimed,
|
|
channel |-> NoChannel, kind |-> "SanitizedError",
|
|
labels |-> {}, rows |-> {}, error |-> "restricted",
|
|
result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
fault == [worker |-> w, actor |-> a, community |-> claimed,
|
|
reason |-> "missing_tenant_context"]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ queryFaults' = queryFaults \cup {fault}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
channelLessReads, authRegistrations, feedReads, auxReads,
|
|
openChannels, auditHeads, acceptedWrites, duplicateWrites,
|
|
createdChannels>>
|
|
ELSE \* mapped host: stamp the host-resolved community, channel-less.
|
|
LET key == [community |-> resolved, id |-> id]
|
|
row == [id |-> id, community |-> resolved, channel |-> NoChannel,
|
|
author |-> a, source |-> "message"]
|
|
obs == [worker |-> w, actor |-> a, community |-> resolved,
|
|
channel |-> NoChannel, kind |-> "WriteResult",
|
|
labels |-> {resolved}, rows |-> {row}, error |-> NoError,
|
|
result |-> "Inserted", verdict |-> "None", audit |-> NoAudit]
|
|
wr == [worker |-> w, id |-> id, community |-> resolved,
|
|
channel |-> NoChannel, host |-> host, author |-> a, claimedCommunity |-> claimed]
|
|
IN
|
|
/\ key \notin MessageKeys
|
|
/\ messages' = messages \cup {row}
|
|
/\ observations' = observations \cup {obs}
|
|
/\ acceptedWrites' = acceptedWrites \cup {wr}
|
|
/\ UNCHANGED <<projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* Duplicate / no-op write outcome (scoped conflict on (community_id, id)). This
|
|
\* is client-observable write surface -- the "Duplicate" WriteResult exposes the
|
|
\* scoped existence/conflict rows -- so it carries the SAME host-axis obligation as
|
|
\* WriteInsert. The community is resolved server-side from the h tag (real ==
|
|
\* ChannelCommunity(ch)); the host is *also* authoritative. An A-host presenting a
|
|
\* B-channel id is a confused deputy on the host axis: it must NOT learn whether
|
|
\* that id already exists in B. So the op fails closed (sanitized error + query
|
|
\* fault, no Duplicate result, nothing recorded) unless HostCommunity[host] = real.
|
|
WriteDuplicate(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E id \in MsgIds, ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities :
|
|
LET real == ChannelCommunity(ch)
|
|
IN
|
|
IF ~(real \in Communities) \/ HostCommunity[host] # real
|
|
THEN \* fail-closed: unknown channel or host/channel disagreement (A-host + B-channel).
|
|
LET obs == [worker |-> w, actor |-> a, community |-> claimed,
|
|
channel |-> ch, kind |-> "SanitizedError",
|
|
labels |-> {}, rows |-> {}, error |-> "restricted",
|
|
result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
fault == [worker |-> w, actor |-> a, community |-> claimed,
|
|
reason |-> "missing_tenant_context"]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ queryFaults' = queryFaults \cup {fault}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
channelLessReads, authRegistrations, feedReads, auxReads,
|
|
openChannels, auditHeads, acceptedWrites, duplicateWrites,
|
|
createdChannels>>
|
|
ELSE \* host agrees with the channel mapping: report the scoped duplicate.
|
|
LET key == [community |-> real, id |-> id]
|
|
conflicts == ScopedConflictRows(real, id)
|
|
obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch,
|
|
kind |-> "WriteResult", labels |-> RowLabels(conflicts), rows |-> conflicts,
|
|
error |-> NoError, result |-> "Duplicate", verdict |-> "None", audit |-> NoAudit]
|
|
wr == [worker |-> w, id |-> id, community |-> real,
|
|
channel |-> ch, host |-> host, author |-> a, claimedCommunity |-> claimed]
|
|
IN
|
|
/\ key \in MessageKeys
|
|
/\ messages' = messages
|
|
/\ observations' = observations \cup {obs}
|
|
/\ duplicateWrites' = duplicateWrites \cup {wr}
|
|
/\ UNCHANGED <<projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, createdChannels, queryFaults>>
|
|
|
|
ReadMessageRows(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E c \in Communities, a \in Actors, ch \in ChannelsExt, host \in Hosts :
|
|
LET rows == VisibleMessageRows(c, a, ch)
|
|
obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch,
|
|
kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows,
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
read == [worker |-> w, community |-> c, host |-> host, actor |-> a]
|
|
IN
|
|
/\ IF ch = NoChannel
|
|
THEN /\ HostCommunity[host] = c
|
|
/\ IsAdmitted(c, a)
|
|
/\ channelLessReads' = channelLessReads \cup {read}
|
|
ELSE channelLessReads' = channelLessReads
|
|
/\ observations' = observations \cup {obs}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
ReadProjectionRows(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E c \in Communities, a \in Actors, ch \in ChannelsExt, host \in Hosts :
|
|
\E rows \in SUBSET VisibleProjectionRows(c, a, ch) :
|
|
LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch,
|
|
kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows,
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
read == [worker |-> w, community |-> c, host |-> host, actor |-> a]
|
|
IN
|
|
/\ IF ch = NoChannel
|
|
THEN /\ HostCommunity[host] = c
|
|
/\ IsAdmitted(c, a)
|
|
/\ channelLessReads' = channelLessReads \cup {read}
|
|
ELSE channelLessReads' = channelLessReads
|
|
/\ observations' = observations \cup {obs}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
ReadByIdRows(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E c \in Communities, a \in Actors, ch \in ChannelsExt, host \in Hosts, id \in MsgIds :
|
|
LET rows == VisibleDirectIdRows(c, a, id, ch)
|
|
obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch,
|
|
kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows,
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
read == [worker |-> w, community |-> c, host |-> host, actor |-> a]
|
|
IN
|
|
/\ IF ch = NoChannel
|
|
THEN /\ HostCommunity[host] = c
|
|
/\ IsAdmitted(c, a)
|
|
/\ channelLessReads' = channelLessReads \cup {read}
|
|
ELSE channelLessReads' = channelLessReads
|
|
/\ observations' = observations \cup {obs}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* No-#h kinds-only feed/global read. The client sends no community metadata; the
|
|
\* relay derives the community from the connection host and fans out across only
|
|
\* channel-less rows plus accessible channels inside that same host community.
|
|
ReadHostFeedRows(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E host \in Hosts, a \in Actors :
|
|
LET c == HostCommunity[host]
|
|
IN
|
|
/\ c \in Communities
|
|
/\ IsAdmitted(c, a)
|
|
/\ [worker |-> w, community |-> c, host |-> host, actor |-> a] \notin feedReads
|
|
/\ LET rows == VisibleHostFeedRows(c, a)
|
|
obs == [worker |-> w, actor |-> a, community |-> c, channel |-> NoChannel,
|
|
kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows,
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
read == [worker |-> w, community |-> c, host |-> host, actor |-> a]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ feedReads' = feedReads \cup {read}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
channelLessReads, authRegistrations, auxReads, openChannels,
|
|
auditHeads, acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* No-#h #e-only aux lookup (reactions/edits/deletes/thread metadata shape). It
|
|
\* resolves by id only after applying the host-community and accessible-channel
|
|
\* fences, so same event id in another community is invisible.
|
|
ReadHostAuxRows(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E host \in Hosts, a \in Actors, id \in MsgIds :
|
|
LET c == HostCommunity[host]
|
|
IN
|
|
/\ c \in Communities
|
|
/\ IsAdmitted(c, a)
|
|
/\ [worker |-> w, community |-> c, host |-> host, actor |-> a, id |-> id] \notin auxReads
|
|
/\ LET rows == VisibleHostAuxRows(c, a, id)
|
|
obs == [worker |-> w, actor |-> a, community |-> c, channel |-> NoChannel,
|
|
kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows,
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
read == [worker |-> w, community |-> c, host |-> host, actor |-> a, id |-> id]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ auxReads' = auxReads \cup {read}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers,
|
|
channelLessReads, authRegistrations, feedReads, openChannels,
|
|
auditHeads, acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* Explicit community predicate was accidentally omitted, but the transaction is
|
|
\* inside TenantContext and Postgres RLS applies the community fence.
|
|
ReadForgotPredicateWithRLS(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E c \in Communities, a \in Actors, ch \in Channels :
|
|
LET candidates == {m \in messages : m.channel \in ScopedAccessible(c, a)}
|
|
rows == RLSRows(c, candidates)
|
|
obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch,
|
|
kind |-> "ResultRows", labels |-> RowLabels(rows), rows |-> rows,
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* If the query does not establish TenantContext at all, RLS must fail closed.
|
|
ReadNoTenantContext(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E c \in Communities, a \in Actors, ch \in Channels :
|
|
LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch,
|
|
kind |-> "ResultRows", labels |-> {}, rows |-> {},
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
fault == [worker |-> w, actor |-> a, community |-> c,
|
|
reason |-> "missing_tenant_context"]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ queryFaults' = queryFaults \cup {fault}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels>>
|
|
|
|
SanitizedError(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E c \in Communities, a \in Actors, ch \in Channels, e \in SanitizedErrors :
|
|
LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch,
|
|
kind |-> "SanitizedError", labels |-> {}, rows |-> {},
|
|
error |-> e, result |-> "None", verdict |-> "None", audit |-> NoAudit]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* Channel-bearing authorization. Community resolves from the channel mapping;
|
|
\* the host is also authoritative. Host/channel disagreement (A-host + B-channel)
|
|
\* is denied -- the host axis of the confused-deputy fence. Even if the actor is
|
|
\* a member of the B-channel, an A-host connection cannot drive a B-channel verdict.
|
|
AuthCheck(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E ch \in Channels, host \in Hosts, a \in Actors, claimed \in Communities :
|
|
LET real == ChannelCommunity(ch)
|
|
hostAgrees == real \in Communities /\ HostCommunity[host] = real
|
|
allowed == hostAgrees /\ ch \in ScopedAccessible(real, a)
|
|
verdict == IF allowed THEN "Allow" ELSE "Deny"
|
|
obs == [worker |-> w, actor |-> a, community |-> real, channel |-> ch,
|
|
kind |-> "AuthVerdict", labels |-> {real}, rows |-> {},
|
|
error |-> NoError, result |-> "None", verdict |-> verdict, audit |-> NoAudit]
|
|
IN
|
|
/\ real \in Communities
|
|
/\ observations' = observations \cup {obs}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
AppendAudit(w) ==
|
|
\E c \in Communities, newHead \in AuditVals :
|
|
/\ newHead # auditHeads[c]
|
|
/\ auditHeads' = [auditHeads EXCEPT ![c] = newHead]
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, observations,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
ObserveAuditHead(w) ==
|
|
/\ Cardinality(observations) < MaxObservations
|
|
/\ \E c \in Communities, a \in Actors, ch \in Channels :
|
|
LET obs == [worker |-> w, actor |-> a, community |-> c, channel |-> ch,
|
|
kind |-> "AuditHead", labels |-> {c}, rows |-> {},
|
|
error |-> NoError, result |-> "None", verdict |-> "None", audit |-> auditHeads[c]]
|
|
IN
|
|
/\ observations' = observations \cup {obs}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* Projection rebuild is privileged internal work. It may touch all communities
|
|
\* and may leave projections temporarily partial, but it emits no observation.
|
|
RebuildProjections(w) ==
|
|
\E rebuilt \in SUBSET DerivedProjectionRows :
|
|
/\ projections' = rebuilt
|
|
/\ UNCHANGED <<messages, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads, observations,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* AUTH to an allowlist-less/open community auto-registers the authenticated npub
|
|
\* in that host-derived community. Later reads/writes still check IsAdmitted; the
|
|
\* row exists because AUTH inserted it, not because the read path bypassed admission.
|
|
AuthenticateOpenCommunity(w) ==
|
|
\E host \in Hosts, a \in Actors :
|
|
LET c == HostCommunity[host]
|
|
row == [community |-> c, actor |-> a]
|
|
reg == [worker |-> w, community |-> c, host |-> host, actor |-> a]
|
|
IN
|
|
/\ c \in OpenCommunities
|
|
/\ row \notin admittedMembers
|
|
/\ admittedMembers' = admittedMembers \cup {row}
|
|
/\ authRegistrations' = authRegistrations \cup {reg}
|
|
/\ UNCHANGED <<messages, projections, memberships, channelLessReads,
|
|
feedReads, auxReads, openChannels, auditHeads, observations,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
\* Channel creation is host-bound: a fresh channel has no community until the
|
|
\* relay accepts kind:9007 on a mapped host, then stamps HostCommunity[host]
|
|
\* atomically. The client sends no community id/tag.
|
|
CreateChannel(w) ==
|
|
\E ch \in Channels, host \in Hosts, a \in Actors :
|
|
LET c == HostCommunity[host]
|
|
created == [worker |-> w, community |-> c, channel |-> ch, host |-> host, actor |-> a]
|
|
IN
|
|
/\ c \in Communities
|
|
/\ ChannelCommunity(ch) = NoCommunity
|
|
/\ ~\E old \in createdChannels : old.channel = ch
|
|
/\ createdChannels' = createdChannels \cup {created}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
observations, acceptedWrites, duplicateWrites, queryFaults>>
|
|
|
|
AdmitMember(w) ==
|
|
\E c \in Communities, a \in Actors :
|
|
LET row == [community |-> c, actor |-> a]
|
|
IN
|
|
/\ admittedMembers' = admittedMembers \cup {row}
|
|
/\ UNCHANGED <<messages, projections, memberships, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
observations, acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
RevokeMember(w) ==
|
|
\E c \in Communities, a \in Actors :
|
|
LET row == [community |-> c, actor |-> a]
|
|
IN
|
|
/\ admittedMembers' = admittedMembers \ {row}
|
|
/\ memberships' = {m \in memberships : ~(m.community = c /\ m.actor = a)}
|
|
/\ channelLessReads' = {r \in channelLessReads : ~(r.community = c /\ r.actor = a)}
|
|
/\ feedReads' = {r \in feedReads : ~(r.community = c /\ r.actor = a)}
|
|
/\ auxReads' = {r \in auxReads : ~(r.community = c /\ r.actor = a)}
|
|
/\ UNCHANGED <<messages, projections, authRegistrations,
|
|
openChannels, auditHeads, observations, acceptedWrites,
|
|
duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
AddMembership(w) ==
|
|
\E ch \in Channels, a \in Actors :
|
|
LET c == ChannelCommunity(ch)
|
|
row == [community |-> c, channel |-> ch, actor |-> a]
|
|
IN
|
|
/\ c \in Communities
|
|
/\ IsAdmitted(c, a)
|
|
/\ memberships' = memberships \cup {row}
|
|
/\ UNCHANGED <<messages, projections, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
observations, acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
RemoveMembership(w) ==
|
|
\E ch \in Channels, a \in Actors :
|
|
LET c == ChannelCommunity(ch)
|
|
row == [community |-> c, channel |-> ch, actor |-> a]
|
|
IN
|
|
/\ c \in Communities
|
|
/\ memberships' = memberships \ {row}
|
|
/\ UNCHANGED <<messages, projections, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, openChannels, auditHeads,
|
|
observations, acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
OpenChannel(w) ==
|
|
\E ch \in Channels :
|
|
/\ ChannelRegistered(ch)
|
|
/\ openChannels' = openChannels \cup {ch}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, auditHeads, observations,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
CloseChannel(w) ==
|
|
\E ch \in Channels :
|
|
/\ openChannels' = openChannels \ {ch}
|
|
/\ UNCHANGED <<messages, projections, memberships, admittedMembers, channelLessReads,
|
|
authRegistrations, feedReads, auxReads, auditHeads, observations,
|
|
acceptedWrites, duplicateWrites, createdChannels, queryFaults>>
|
|
|
|
Next ==
|
|
\E w \in Workers :
|
|
\/ WriteInsert(w)
|
|
\/ WriteInsertGlobal(w)
|
|
\/ WriteDuplicate(w)
|
|
\/ ReadMessageRows(w)
|
|
\/ ReadProjectionRows(w)
|
|
\/ ReadByIdRows(w)
|
|
\/ ReadHostFeedRows(w)
|
|
\/ ReadHostAuxRows(w)
|
|
\/ ReadForgotPredicateWithRLS(w)
|
|
\/ ReadNoTenantContext(w)
|
|
\/ SanitizedError(w)
|
|
\/ AuthCheck(w)
|
|
\/ AppendAudit(w)
|
|
\/ ObserveAuditHead(w)
|
|
\/ RebuildProjections(w)
|
|
\/ AuthenticateOpenCommunity(w)
|
|
\/ CreateChannel(w)
|
|
\/ AdmitMember(w)
|
|
\/ RevokeMember(w)
|
|
\/ AddMembership(w)
|
|
\/ RemoveMembership(w)
|
|
\/ OpenChannel(w)
|
|
\/ CloseChannel(w)
|
|
|
|
BoundedObservations == Cardinality(observations) <= MaxObservations
|
|
|
|
BoundedWitnesses ==
|
|
/\ Cardinality(messages) <= 1
|
|
/\ Cardinality(projections) <= 1
|
|
/\ Cardinality(memberships) <= 1
|
|
/\ Cardinality(admittedMembers) <= 2
|
|
/\ Cardinality(channelLessReads) <= 1
|
|
/\ Cardinality(authRegistrations) <= 1
|
|
/\ Cardinality(feedReads) <= 1
|
|
/\ Cardinality(auxReads) <= 1
|
|
/\ Cardinality(openChannels) <= 1
|
|
/\ Cardinality(acceptedWrites) <= 1
|
|
/\ Cardinality(duplicateWrites) <= 1
|
|
/\ Cardinality(createdChannels) <= 1
|
|
/\ Cardinality(queryFaults) <= 1
|
|
|
|
Spec == Init /\ [][Next]_vars
|
|
|
|
------------------------------------------------------------------------------
|
|
\* SAFETY PROPERTIES
|
|
|
|
\* NI (master): no observation scoped to community C may be influenced by a row,
|
|
\* projection, audit head, auth decision, write-conflict source, or error source
|
|
\* labeled outside C. This is the single-run label/taint encoding of the
|
|
\* two-execution non-interference theorem.
|
|
Inv_NonInterference ==
|
|
\A o \in observations : o.labels \subseteq {o.community}
|
|
|
|
\* Label propagation: observation labels are not arbitrary annotations; they are
|
|
\* derived from the dependencies each observation can reveal.
|
|
Inv_LabelPropagation ==
|
|
\A o \in observations :
|
|
/\ (o.kind \in {"ResultRows", "WriteResult"} => o.labels = RowLabels(o.rows))
|
|
/\ (o.kind = "SanitizedError" => o.labels = {} /\ o.error \in SanitizedErrors)
|
|
/\ (o.kind = "AuditHead" => o.labels = {o.community} /\ o.audit \in AuditVals)
|
|
/\ (o.kind = "AuthVerdict" => o.labels = {ChannelCommunity(o.channel)} /\ o.community = ChannelCommunity(o.channel))
|
|
|
|
\* I1 read confinement follows from NI + label propagation, but is kept as a
|
|
\* legible mutation target for the current get_accessible_channel_ids landmine.
|
|
Inv_ReadConfinement ==
|
|
\A o \in observations :
|
|
o.kind = "ResultRows" => \A r \in o.rows : r.community = o.community
|
|
|
|
\* I2 resolution fence: persisted messages and write/auth observations are
|
|
\* labeled by the *resolved* community, never a client-supplied claim. For a
|
|
\* channel-bearing op the resolver is the server-owned channel->community mapping
|
|
\* (the h tag is not the source). For a channel-less op (channel = NoChannel) the
|
|
\* resolver is the connection's host (P-RESOLVE-HOST): the stamped community is a
|
|
\* real community derived from the host, never the NoCommunity sentinel and never
|
|
\* the adversary-controlled claimedCommunity. At N=1 (one host -> one community)
|
|
\* the two branches coincide.
|
|
Inv_ResolutionFence ==
|
|
/\ \A m \in messages :
|
|
IF m.channel = NoChannel THEN m.community \in Communities
|
|
ELSE /\ ChannelRegistered(m.channel)
|
|
/\ m.community = ChannelCommunity(m.channel)
|
|
/\ \A w \in acceptedWrites :
|
|
IF w.channel = NoChannel THEN w.community \in Communities
|
|
ELSE w.community = ChannelCommunity(w.channel)
|
|
/\ \A w \in duplicateWrites :
|
|
IF w.channel = NoChannel THEN w.community \in Communities
|
|
ELSE w.community = ChannelCommunity(w.channel)
|
|
/\ \A o \in observations :
|
|
o.kind \in {"WriteResult", "AuthVerdict"} =>
|
|
(IF o.channel = NoChannel THEN o.community \in Communities
|
|
ELSE o.community = ChannelCommunity(o.channel))
|
|
|
|
\* I2-host fail-closed binding: EVERY accepted write -- channel-bearing or
|
|
\* channel-less -- AND every observable duplicate/no-op outcome carries the host it
|
|
\* came in on, and that host's mapping is a real community equal to the write's
|
|
\* stored community (P-RESOLVE-HOST). For channel-less writes this is the
|
|
\* host->community derivation; for channel-bearing writes and duplicate/no-op
|
|
\* outcomes it is the host/channel agreement fence (HostCommunity[host] =
|
|
\* ChannelCommunity(ch)). This makes BOTH "an unmapped host defaults to a tenant"
|
|
\* and "an A-host can drive a B-channel op (insert OR duplicate-probe)" CAUGHT
|
|
\* mutations: the fail-closed branches of WriteInsert/WriteInsertGlobal/WriteDuplicate
|
|
\* write/record nothing on disagreement, so no accepted write and no recorded
|
|
\* duplicate can carry a host whose mapping is NoCommunity or disagrees with the stamp.
|
|
Inv_HostBindingFence ==
|
|
/\ \A w \in acceptedWrites :
|
|
/\ w.host \in Hosts
|
|
/\ HostCommunity[w.host] \in Communities
|
|
/\ w.community = HostCommunity[w.host]
|
|
/\ \A w \in duplicateWrites :
|
|
/\ w.host \in Hosts
|
|
/\ HostCommunity[w.host] \in Communities
|
|
/\ w.community = HostCommunity[w.host]
|
|
/\ \A ch \in createdChannels :
|
|
/\ ch.host \in Hosts
|
|
/\ HostCommunity[ch.host] \in Communities
|
|
/\ ch.community = HostCommunity[ch.host]
|
|
/\ ChannelCommunity(ch.channel) = ch.community
|
|
|
|
\* Channel-community immutability: a runtime-created channel may be stamped at
|
|
\* most once, and all durable ownership rows for a channel agree on the same
|
|
\* host-resolved community. This makes the old constant-function immutability
|
|
\* guarantee explicit now that fresh channel creation is modeled as state.
|
|
Inv_ChannelCommunityImmutable ==
|
|
/\ \A c1, c2 \in createdChannels :
|
|
c1.channel = c2.channel => c1.community = c2.community
|
|
/\ \A created \in createdChannels :
|
|
/\ InitialChannelOwners[created.channel] = NoCommunity
|
|
/\ created.community = HostCommunity[created.host]
|
|
|
|
\* I2-admission fence: NIP-43 member-npub admission is scoped by community, not
|
|
\* relay-global pubkey. Active channel memberships and current channel-less read
|
|
\* capabilities may only exist when the actor is admitted in that same community;
|
|
\* channel-less read capabilities also carry the host that resolved that community.
|
|
\* This records the observable admission capability, not historical message rows:
|
|
\* revoking admission removes current memberships/reads but does not relabel rows
|
|
\* already written while admitted.
|
|
Inv_AdmissionFence ==
|
|
/\ \A m \in memberships : IsAdmitted(m.community, m.actor)
|
|
/\ \A r \in channelLessReads :
|
|
/\ IsAdmitted(r.community, r.actor)
|
|
/\ HostCommunity[r.host] = r.community
|
|
/\ \A r \in authRegistrations :
|
|
/\ r.community \in OpenCommunities
|
|
/\ HostCommunity[r.host] = r.community
|
|
/\ \A r \in feedReads :
|
|
/\ IsAdmitted(r.community, r.actor)
|
|
/\ HostCommunity[r.host] = r.community
|
|
/\ \A r \in auxReads :
|
|
/\ IsAdmitted(r.community, r.actor)
|
|
/\ HostCommunity[r.host] = r.community
|
|
|
|
\* TLA-side anti-vacuity / reachability witnesses for surfaces that could
|
|
\* otherwise pass only because their actions never fire. These are intentionally
|
|
\* false invariants: checking one ad hoc with INVARIANT (plus the normal
|
|
\* constraints) must produce a short counterexample trace. If it stays green, the
|
|
\* corresponding action is unreachable and the safety proof is vacuous there.
|
|
Probe_OpenAuthRegistration_Unreachable ==
|
|
authRegistrations = {}
|
|
|
|
Probe_CreatedChannel_Unreachable ==
|
|
createdChannels = {}
|
|
|
|
Probe_HostFeedRead_Unreachable ==
|
|
feedReads = {}
|
|
|
|
Probe_HostAuxRead_Unreachable ==
|
|
auxReads = {}
|
|
|
|
\* I3a append persistence: every accepted append remains present in the shared log.
|
|
Inv_AcceptedWritesPersist ==
|
|
\A w \in acceptedWrites : MessageRow(w.id, w.community, w.channel, w.author) \in messages
|
|
|
|
\* I3b scoped idempotence: ids are unique within a community, not globally. This
|
|
\* permits two different communities to store the same content hash while avoiding
|
|
\* the event-id existence oracle as a cross-tenant write-conflict channel.
|
|
Inv_MessageKeyUnique ==
|
|
\A m1, m2 \in messages :
|
|
(m1.community = m2.community /\ m1.id = m2.id) => (m1 = m2)
|
|
|
|
\* I4 fail-closed backstop: missing TenantContext serves no rows. Dropped SQL
|
|
\* predicates inside a valid TenantContext are covered by RLSRows and NI.
|
|
Inv_NoTenantContextFailsClosed ==
|
|
\A o \in observations :
|
|
(o.kind = "ResultRows" /\ o.labels = {}) => o.rows = {}
|
|
|
|
\* Projection rows are derived-only and inherit the source event label.
|
|
Inv_ProjectionDerived == projections \subseteq DerivedProjectionRows
|
|
|
|
\* Sanitized error alphabet is the only client-visible error surface in scope.
|
|
Inv_SanitizedErrors ==
|
|
\A o \in observations :
|
|
o.kind = "SanitizedError" => o.error \in SanitizedErrors
|
|
|
|
Safety ==
|
|
/\ TypeOK
|
|
/\ Inv_NonInterference
|
|
/\ Inv_LabelPropagation
|
|
/\ Inv_ReadConfinement
|
|
/\ Inv_ResolutionFence
|
|
/\ Inv_HostBindingFence
|
|
/\ Inv_ChannelCommunityImmutable
|
|
/\ Inv_AdmissionFence
|
|
/\ Inv_AcceptedWritesPersist
|
|
/\ Inv_MessageKeyUnique
|
|
/\ Inv_NoTenantContextFailsClosed
|
|
/\ Inv_ProjectionDerived
|
|
/\ Inv_SanitizedErrors
|
|
=============================================================================
|