Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ This is the most intricate part of the codebase and where most current work happ

**Tombstones.** A departed group member's row stays with `state=2`, `left_at`, a 1h TTL and the departure's roster version, so re-joins are idempotent updates and delayed duplicates of an undone join can be distinguished from news. Readers trust `state`, never the clock. "Formerly a member" is not durable.

**Rules (`chat/rules.go`).** The proto is the single vocabulary: what clients see in `Metadata.rules` is exactly what `RuleEvaluator` evaluates. Clients create a public group with listener rules (staff-only, minimum balance in any ISO 4217 currency; a minimum listener balance of at least the currency's minimum transfer value is required) and, optionally, one speaker rule: a minimum speaker balance (same floor), which must be in the listener balance's currency and mints and larger than it by at least the currency's minimum transfer value (float error forgiven by a few ulps, `exceedsByMinimumTransferValue`; `RulesFromProto`); every other speaker rule is rejected at creation, and the creator must satisfy the speaker rule too. A group can also carry a `CreatorRequirement` speaker rule (only the creator speaks) when its record has `Chat.IsCreatorOnlySpeaker` (DynamoDB `is_creator_only_speaker`). No RPC sets it; it is only read from the record, and must be written with the group, never onto one that exists (the cache below would not see it until restart). The rule names no one: `Store.GetGroupRules` returns a `chat.ChatRules` (the rules plus the recorded `CreatorID`, one projection of the canonical item), and the rule is answered off that creator with no read of its own; `StartChat` evaluates its request's rules with the caller as the creator. A group with the flag but no recorded creator lets no one speak, and a creator who has left is refused on membership first. A group can likewise carry a `MinimumBalanceRequirement` speaker rule when its record has `Chat.MinimumSpeakerBalance` (DynamoDB `min_speaker_balance`, the same map encoding as `min_listener_balance`), listed after the creator rule; set by `StartChat` when asked for (see above), and otherwise like the creator flag, written with the group only. It is evaluated like the listener balance and on top of the listener rules; it is meant to cover the listener balance (same currency and mints, at least the amount), which every `StartChat` group's does, and when it does a speak check skips the listener balance and values once (`coversBalance` in `CanSpeakWithRules`), otherwise both are valued. The only other speaker rule a client sees is `Never`, carried by any DM with the Flipcash team account and decided per read by the `RuleEvaluator` off the `teamUserID` it is built with (never stored): `RuleEvaluator.RulesOf` is what `hydrate` shows as `Metadata.rules` so the client knows not to offer a composer, and `rulesFor` gives the same rule to `CanSpeak` (off the IDs, with no read, exact for a member), which no one satisfies, so it is the refusal itself: messaging's `canSpeak` is `Access.CanSpeak` and nothing else. A USD minimum is compared in quarks against the USDF total; any other currency goes through OCP's `currency_codes` valuation (`balance.Client.GetTotalFiatValue`) with half a minor unit of slack, and a currency OCP cannot price is `INVALID_RULES` at creation and an evaluation error (never a pass) afterwards. Rules and the creator are immutable after creation, so `chat/cache` holds both forever. Unknown rule kind = error (admit no one), never a pass. Rules are evaluated against current state, not join-time state.
**Rules (`chat/rules.go`).** The proto is the single vocabulary: what clients see in `Metadata.rules` is exactly what `RuleEvaluator` evaluates. Clients create a public group with listener rules (staff-only, minimum balance in any ISO 4217 currency; a minimum listener balance of at least the currency's minimum transfer value is required) and, optionally, one speaker rule: a minimum speaker balance (same floor), which must be in the listener balance's currency and mints and larger than it by at least the currency's minimum transfer value (float error forgiven by a few ulps, `exceedsByMinimumTransferValue`; `RulesFromProto`); every other speaker rule is rejected at creation, and the creator must satisfy the speaker rule too. A group can also carry a `CreatorRequirement` speaker rule (only the creator speaks) when its record has `Chat.IsCreatorOnlySpeaker` (DynamoDB `is_creator_only_speaker`). No RPC sets it; it is only read from the record, and must be written with the group, never onto one that exists (the cache below would not see it until restart). The rule names no one: `Store.GetGroupRules` returns a `chat.ChatRules` (the rules plus the recorded `CreatorID`, one projection of the canonical item), and the rule is answered off that creator with no read of its own; `StartChat` evaluates its request's rules with the caller as the creator. A group with the flag but no recorded creator lets no one speak, and a creator who has left is refused on membership first. A group can likewise carry a `MinimumBalanceRequirement` speaker rule when its record has `Chat.MinimumSpeakerBalance` (DynamoDB `min_speaker_balance`, the same map encoding as `min_listener_balance`), listed after the creator rule; set by `StartChat` when asked for (see above), and otherwise like the creator flag, written with the group only. It is evaluated like the listener balance and on top of the listener rules; it is meant to cover the listener balance (same currency and mints, at least the amount), which every `StartChat` group's does, and when it does a speak check skips the listener balance and values once (`coversBalance` in `CanSpeakWithRules`), otherwise both are valued. The only other speaker rule a client sees is `Never`, carried by any DM with the Flipcash team account and decided per read by the `RuleEvaluator` off the `teamUserID` it is built with (never stored): `RuleEvaluator.RulesOf` is what `hydrate` shows as `Metadata.rules` so the client knows not to offer a composer, and `rulesFor` gives the same rule to `CanSpeak` (off the IDs, with no read, exact for a member), which no one satisfies, so it is the refusal itself: messaging's `canSpeak` is `Access.CanSpeak` and nothing else. Every minimum balance is compared with half a minor unit of slack (half a cent for USD): a USD minimum in quarks against the USDF total, any other currency through OCP's `currency_codes` valuation (`balance.Client.GetTotalFiatValue`), and a currency OCP cannot price is `INVALID_RULES` at creation and an evaluation error (never a pass) afterwards. Rules and the creator are immutable after creation, so `chat/cache` holds both forever. Unknown rule kind = error (admit no one), never a pass. Rules are evaluated against current state, not join-time state.

**Group management.** `StartChat` / `JoinChat` / `LeaveChat` are open to every registered user (nothing in these packages gates on client version). DMs always deny join/leave. Membership is checked before rules so a re-join is a no-op. Every real transition publishes a `RosterUpdate` to both the user and chat topics (`publishRosterUpdate`): the subject's own devices get `MemberJoined` (with metadata) or `MemberLeft` on their user topic, and the other members get only `RosterUpdate.MembershipChanged` on the chat topic (excluding the subject), carrying the same `RosterSummary` and **naming no one** (decided 2026-10-06; when members who have sent are revealed, revealed members' joins and leaves will be named again). Creation tells only the creator. Creation validates rules, checks the creator satisfies them, moderates the title then the description and attaches the profile picture then the cover picture *before* the record is written.

Expand Down
12 changes: 6 additions & 6 deletions chat/access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type accessFixture struct {
usdf *commonpb.PublicKey

// gated requires `requirement` USD to listen. funded holds exactly that,
// unfunded one quark less.
// unfunded a cent less, past the half-cent slack.
gated *Chat
funded *commonpb.UserId
unfunded *commonpb.UserId
Expand All @@ -50,7 +50,7 @@ func newAccessFixture(t *testing.T) *accessFixture {
f.funded = model.MustGenerateUserID()
f.ocpBalance.set(f.accounts.bind(f.funded), f.usdf, ocp_common.ToCoreMintQuarks(accessRequirement))
f.unfunded = model.MustGenerateUserID()
f.ocpBalance.set(f.accounts.bind(f.unfunded), f.usdf, ocp_common.ToCoreMintQuarks(accessRequirement)-1)
f.ocpBalance.set(f.accounts.bind(f.unfunded), f.usdf, ocp_common.ToCoreMintQuarks(accessRequirement)-ocp_common.CoreMintQuarksPerUnit/100)

f.gated = f.chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, MinimumListenerBalance: &MinimumBalance{Currency: "usd", NativeAmount: accessRequirement}})
return f
Expand Down Expand Up @@ -795,7 +795,7 @@ func TestAccess_SpeakerAdmission(t *testing.T) {
require.Equal(t, asked+2, f.ocpBalance.asked)
f.setBalance(f.unfunded, accessRequirement)
require.True(t, canSpeak(f.gated.ID, f.unfunded))
f.ocpBalance.set(f.accounts.keys[string(f.unfunded.Value)], f.usdf, ocp_common.ToCoreMintQuarks(accessRequirement)-1)
f.ocpBalance.set(f.accounts.keys[string(f.unfunded.Value)], f.usdf, ocp_common.ToCoreMintQuarks(accessRequirement)-ocp_common.CoreMintQuarksPerUnit/100)

// Membership is read on every ask: a remembered member who leaves is
// refused at once, with nothing evaluated. Leaving does not forget the
Expand Down Expand Up @@ -900,16 +900,16 @@ func TestAccess_Governance(t *testing.T) {
rules := NewRuleEvaluator(f.accounts, balance.NewClient(zaptest.NewLogger(t), f.accounts, f.ocpBalance), f.chats, team)
a := NewAccess(f.chats, rules, WithListenerAdmissionTTL(0))

// rich users hold exactly the requirement, poor ones a quark less; "in"
// rich users hold exactly the requirement, poor ones a cent less; "in"
// users are made members of the chats that list them, "out" users never.
bound := func(quarks uint64) *commonpb.UserId {
u := model.MustGenerateUserID()
f.ocpBalance.set(f.accounts.bind(u), f.usdf, quarks)
return u
}
requirement := ocp_common.ToCoreMintQuarks(accessRequirement)
richIn, poorIn := bound(requirement), bound(requirement-1)
richOut, poorOut := bound(requirement), bound(requirement-1)
richIn, poorIn := bound(requirement), bound(requirement-ocp_common.CoreMintQuarksPerUnit/100)
richOut, poorOut := bound(requirement), bound(requirement-ocp_common.CoreMintQuarksPerUnit/100)
creator, peer := bound(requirement), model.MustGenerateUserID()
minimum := func() *MinimumBalance { return &MinimumBalance{Currency: "usd", NativeAmount: accessRequirement} }

Expand Down
28 changes: 16 additions & 12 deletions chat/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -593,8 +593,8 @@ func (e *RuleEvaluator) satisfies(ctx context.Context, rules RuleSet, kind any,
// satisfies the listener one: the same currency, the same set of mints (in
// any order; none is any mint, and covers only none), and at least the
// amount. satisfiesMinimumBalance compares either in a way that keeps the
// order of the amounts — rounded to the quark for USD, against OCP's
// valuation with the same slack otherwise — so a balance that clears the
// order of the amounts — in quarks for USD, against OCP's valuation
// otherwise, with the same slack either way — so a balance that clears the
// larger requirement clears the smaller.
func coversBalance(speaker, listener *chatpb.MinimumBalanceRequirement) bool {
return speaker.GetAmount().GetCurrency() == listener.GetAmount().GetCurrency() &&
Expand All @@ -621,36 +621,41 @@ func sameMints(a, b []*commonpb.PublicKey) bool {
// satisfiesMinimumBalance reports whether userID holds at least the required
// amount, in the required mints, right now.
//
// Every currency is compared with half of its smallest transferable unit of
// slack (half a cent for USD), as intent validation allows on a rate-derived
// payment, so a balance a fraction of a minor unit under the requirement
// satisfies it.
//
// A USD requirement is answered in quarks: the balance client's core mint
// total is USDF, a USD stablecoin, so the requirement compares directly
// against it with no exchange rate in between, the requirement rounded to the
// nearest quark, so a balance that is exactly the requirement satisfies it
// whatever the float arithmetic on the way in. The path asks OCP for no
// valuation, so a USD group is unaffected by what OCP can price.
// against it with no exchange rate in between, the requirement and the slack
// each rounded to the nearest quark. The path asks OCP for no valuation, so a
// USD group is unaffected by what OCP can price.
//
// Any other currency is answered by OCP's valuation of the same holdings in
// that currency (see balance.Client.GetTotalFiatValue). The value is a quoted
// rate applied to a quark total, so it can land a fraction of a minor unit
// under a requirement it meets through rounding alone; half of the currency's
// smallest transferable unit of slack is allowed, as intent validation allows
// on a rate-derived payment. A currency OCP cannot value is, like an unknown
// rule kind, an error and never a pass.
// under a requirement it meets through rounding alone, which the slack
// absorbs. A currency OCP cannot value is, like an unknown rule kind, an error
// and never a pass.
//
// A user with no owner account holds nothing, and fails as a zero balance would;
// a balance that cannot be read is an error, so the gate is never left
// unenforced.
func (e *RuleEvaluator) satisfiesMinimumBalance(ctx context.Context, req *chatpb.MinimumBalanceRequirement, userID *commonpb.UserId) (bool, error) {
currency := currency_lib.Code(req.GetAmount().GetCurrency())
tolerance := 0.5 * minimumTransferValue(currency)
if currency == currency_lib.USD {
required := uint64(math.Round(req.GetAmount().GetNativeAmount() * float64(ocp_common.CoreMintQuarksPerUnit)))
slack := uint64(math.Round(tolerance * float64(ocp_common.CoreMintQuarksPerUnit)))

held, err := e.balances.GetTotalUsdfBalance(ctx, userID, req.GetMints()...)
if errors.Is(err, balance.ErrNotFound) {
held = 0
} else if err != nil {
return false, err
}
return held >= required, nil
return held+slack >= required, nil
}

held, err := e.balances.GetTotalFiatValue(ctx, userID, currency, req.GetMints()...)
Expand All @@ -659,6 +664,5 @@ func (e *RuleEvaluator) satisfiesMinimumBalance(ctx context.Context, req *chatpb
} else if err != nil {
return false, err
}
tolerance := 0.5 * minimumTransferValue(currency)
return held >= req.GetAmount().GetNativeAmount()-tolerance, nil
}
32 changes: 25 additions & 7 deletions chat/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -749,18 +749,18 @@ func TestRuleEvaluator_MinimumBalance(t *testing.T) {
usdf := model.MustGenerateKeyPair().Proto()
other := model.MustGenerateKeyPair().Proto()

// holder holds exactly the requirement; underfunded is one quark short; unbound
// holder holds exactly the requirement; underfunded is a cent short; unbound
// has no owner account at all.
const requirement = 100
holder := model.MustGenerateUserID()
ocpBalance.set(accounts.bind(holder), usdf, ocp_common.ToCoreMintQuarks(requirement))
underfunded := model.MustGenerateUserID()
ocpBalance.set(accounts.bind(underfunded), usdf, ocp_common.ToCoreMintQuarks(requirement)-1)
ocpBalance.set(accounts.bind(underfunded), usdf, ocp_common.ToCoreMintQuarks(requirement)-ocp_common.CoreMintQuarksPerUnit/100)
unbound := model.MustGenerateUserID()

// A USD requirement is compared straight against the USDF valuation: a
// balance exactly at the requirement satisfies it, one quark under does
// not, and a user with no owner account holds nothing.
// balance exactly at the requirement satisfies it, a cent under does not,
// and a user with no owner account holds nothing.
gated := chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, MinimumListenerBalance: &MinimumBalance{Currency: "usd", NativeAmount: requirement}})
for _, u := range []*commonpb.UserId{holder} {
ok, err := e.CanListen(ctx, gated.ID, u)
Expand All @@ -779,11 +779,29 @@ func TestRuleEvaluator_MinimumBalance(t *testing.T) {
require.False(t, ok)
}

// Half a cent of slack is allowed, as for any other currency: half a cent
// under the requirement satisfies it, a quark more under does not.
halfCent := ocp_common.CoreMintQuarksPerUnit / 200
ocpBalance.set(accounts.keys[string(underfunded.Value)], usdf, ocp_common.ToCoreMintQuarks(requirement)-halfCent)
ok, err := e.CanListen(ctx, gated.ID, underfunded)
require.NoError(t, err)
require.True(t, ok)
ok, err = e.CanSpeak(ctx, gated.ID, underfunded)
require.NoError(t, err)
require.True(t, ok)
ocpBalance.set(accounts.keys[string(underfunded.Value)], usdf, ocp_common.ToCoreMintQuarks(requirement)-halfCent-1)
ok, err = e.CanListen(ctx, gated.ID, underfunded)
require.NoError(t, err)
require.False(t, ok)
ok, err = e.CanSpeak(ctx, gated.ID, underfunded)
require.NoError(t, err)
require.False(t, ok)

// A fractional requirement is rounded to the quark, so a balance exactly
// at the requirement satisfies it despite the float on the way in.
cents := chats.put(&Chat{ID: MustGenerateGroupChatID(), Type: chatpb.ChatType_GROUP, MinimumListenerBalance: &MinimumBalance{Currency: "usd", NativeAmount: 0.1 + 0.2}})
ocpBalance.set(accounts.keys[string(underfunded.Value)], usdf, ocp_common.CoreMintQuarksPerUnit*3/10)
ok, err := e.CanListen(ctx, cents.ID, underfunded)
ok, err = e.CanListen(ctx, cents.ID, underfunded)
require.NoError(t, err)
require.True(t, ok)

Expand Down Expand Up @@ -899,7 +917,7 @@ func TestRuleEvaluator_MinimumSpeakerBalance(t *testing.T) {
holder := model.MustGenerateUserID()
ocpBalance.set(accounts.bind(holder), usdf, ocp_common.ToCoreMintQuarks(requirement))
underfunded := model.MustGenerateUserID()
ocpBalance.set(accounts.bind(underfunded), usdf, ocp_common.ToCoreMintQuarks(requirement)-1)
ocpBalance.set(accounts.bind(underfunded), usdf, ocp_common.ToCoreMintQuarks(requirement)-ocp_common.CoreMintQuarksPerUnit/100)
unbound := model.MustGenerateUserID()

// A speaker balance gates speaking alone: everyone listens, and only a
Expand Down Expand Up @@ -943,7 +961,7 @@ func TestRuleEvaluator_MinimumSpeakerBalance(t *testing.T) {
ok, err = e.CanSpeak(ctx, creatorOnly.ID, underfunded)
require.NoError(t, err)
require.True(t, ok)
ocpBalance.set(accounts.keys[string(underfunded.Value)], usdf, ocp_common.ToCoreMintQuarks(requirement)-1)
ocpBalance.set(accounts.keys[string(underfunded.Value)], usdf, ocp_common.ToCoreMintQuarks(requirement)-ocp_common.CoreMintQuarksPerUnit/100)

// Speaker rules apply on top of listener rules, but a speaker balance
// that covers the listener one (same currency and mints, at least the
Expand Down
8 changes: 4 additions & 4 deletions chat/tests/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2978,10 +2978,10 @@ func testServer_JoinChat_MinimumBalanceRule(t *testing.T, s chat.Store) {
resp := e.mustJoinChat(e.keys, group.ID)
require.Equal(t, chatpb.JoinChatResponse_RULES_NOT_SATISFIED, resp.Result)

// One quark short is still short.
// A cent short is still short.
_, err := e.accounts.Bind(e.ctx, e.userID, e.keys.Proto())
require.NoError(t, err)
e.ocpBalance.setBalance(e.keys.Proto(), ocp_common.ToCoreMintQuarks(requirement)-1)
e.ocpBalance.setBalance(e.keys.Proto(), ocp_common.ToCoreMintQuarks(requirement)-ocp_common.CoreMintQuarksPerUnit/100)
resp = e.mustJoinChat(e.keys, group.ID)
require.Equal(t, chatpb.JoinChatResponse_RULES_NOT_SATISFIED, resp.Result)
e.requireNoRosterUpdates(e.userID, group.ID)
Expand Down Expand Up @@ -4287,10 +4287,10 @@ func testServer_StartChat_RulesNotSatisfied(t *testing.T, s chat.Store) {
require.Equal(t, chatpb.StartChatResponse_RULES_NOT_SATISFIED, resp.Result)
require.Nil(t, resp.Chat)

// ...as is one a quark short.
// ...as is one a cent short.
_, err := e.accounts.Bind(e.ctx, e.userID, e.keys.Proto())
require.NoError(t, err)
e.ocpBalance.setBalance(e.keys.Proto(), ocp_common.ToCoreMintQuarks(startChatMinimumBalance)-1)
e.ocpBalance.setBalance(e.keys.Proto(), ocp_common.ToCoreMintQuarks(startChatMinimumBalance)-ocp_common.CoreMintQuarksPerUnit/100)
resp = e.mustStartGroupChat(e.keys, groupParams("Whales"))
require.Equal(t, chatpb.StartChatResponse_RULES_NOT_SATISFIED, resp.Result)

Expand Down
Loading
Loading