Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: fall back to the Everyone group for AI spend attribution - #27364

Merged
ssncferreira merged 4 commits into
mainfrom
ssncf/aigov-everyone-group-fallback
Jul 23, 2026
Merged

feat: fall back to the Everyone group for AI spend attribution#27364
ssncferreira merged 4 commits into
mainfrom
ssncf/aigov-everyone-group-fallback

Conversation

@ssncferreira

@ssncferreira ssncferreira commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Description

Previously, a user with no per-user override and no membership in a budgeted group had no effective group, so their AI spend was attributed nowhere and was, therefore, untracked. This change falls back to the organization's Everyone group when no override or group budget applies.

Since every user in an organization is implicitly a member of that org's Everyone group, spend is now attributed and tracked for any user with organization membership. A user with no organization membership resolves to no group, so their daily spend is not incremented and a warning is logged.

The fallback is unlimited, so enforcement is unaffected: only override and group budgets can block requests. For users in multiple organizations, an existing budget on any Everyone group is still chosen by the "highest" policy; when none is budgeted, the fallback prefers the default org, then orders by earliest organization membership.

Changes

  • Add ResolveUserEffectiveGroup and the GetUserEveryoneFallbackGroup query: resolve override → group budget → Everyone group fallback.
  • Attribute token-usage spend and the user AI spend endpoint via the fallback, so unbudgeted users resolve to their Everyone group instead of null.
  • Update GetGroupMembersAISpend to surface the Everyone fallback as the effective group.
  • Update GetHighestGroupAIBudgetByUser to break ties by earliest organization membership, then group ID, keeping multi-org resolution deterministic and consistent with the fallback.
  • For multi-org users with no budget anywhere, the fallback picks the Everyone group deterministically: prefer the default org, then order by earliest organization membership.

Closes https://linear.app/codercom/issue/AIGOV-509/fall-back-to-the-everyone-group-for-spend-attribution

Note

Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

Comment on lines +92 to +93
organizations.is_default DESC, -- prefer the default org
organizations.name ASC -- organization name tiebreak

@ssncferreira ssncferreira Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for the Everyone fallback is as follows:

  1. If the user belongs to the default organization, use the Everyone group from that org.
  2. If the user does not belong to the default organization and belongs to multiple orgs, use the org that comes first alphabetically (by name).

GetHighestGroupAIBudgetByUser was also updated to break ties by org name and then group name, for the case where a user belongs to two orgs that each have a same-named group with the same budget. This keeps tie-breaking consistent across the codebase.

This logic was chosen because ordering by names is easier for customers to reason about than ordering by ids. The trade-off is that org and group names are mutable, so the resolved effective group can change on rename. For example, a user in org-a and org-b resolves to org-a's Everyone group; if org-a is later renamed to org-c, the effective group shifts to org-b's Everyone group.

This is an edge case, but we should aim for behavior that is less surprising and consistent across the codebase. Names are mutable, so renames shift the result (however, this also depends on how frequent this sort of operation is 🤔 ). As a result, maybe the initial approach of ordering by an immutable key such as the first org joined (organization_members.created_at) would be a better approach. This would be stable under renames and new memberships, changing only if the user leaves that org. wdyt?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user belongs to both the default organization and multiple orgs, should we behave as if they were not a member of the default organization?

I'm skeptical on using name ordering as a tie-breaker. organization_members.created_at seems like a more stable solution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user belongs to both the default organization and multiple orgs, should we behave as if they were not a member of the default organization?

No, whenever the user belongs to the default organization, that will be the one used (even in a multi-org scenario). The step 2, is only when the user does not belong to the default organization and belongs to multiple orgs.

I'm skeptical on using name ordering as a tie-breaker. organization_members.created_at seems like a more stable solution.

ok, so I will change this 👍 Thanks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in d42be83
We still need to order by organizations.id as a final tiebreaker, for the (rare) case a member has organization_members.created_at timestamp for 2 different orgs. GetHighestGroupAIBudgetByUser was updated as well with the same logic.

if errors.Is(err, sql.ErrNoRows) {
// This should not happen, as a user should always be a member of an
// organization and its associated Everyone group.
return EffectiveGroup{}, false, nil

@ssncferreira ssncferreira Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, a user should always belong to an org. However, it seems the codebase allows a user to end up orgless in a few cases, e.g. an admin removing a user from their last org (only self-removal is blocked), or the user's only org being deleted. This shouldn't happen in normal circumstances, but the code handles it defensively nevertheless.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call 👍
For the record, it looks like in most cases a 'dis-organized' user can't really do anything of much substance unless they have a site-wide admin role.

@linear-code

linear-code Bot commented Jul 21, 2026

Copy link
Copy Markdown

AIGOV-509

@ssncferreira

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-07-21 12:12 UTC by @ssncferreira
Spend: $24.07 / $100.00

Review history
  • R1 (2026-07-21): 17 reviewers, 1 Nit, 2 Note, 3 P3, 1 P4, COMMENT. Review

deep-review v0.9.0 | Round 1 | 9bd4cf2..a932e25

Last posted: Round 1, 7 findings (3 P3, 1 P4, 1 Nit, 2 Note), COMMENT. Review

Finding inventory

Finding inventory - PR #27364

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 Note Open coderd/database/queries/aicostcontrol.sql:81 Everyone-fallback ordering duplicated across GetUserEveryoneFallbackGroup and the user_fallback_group CTE; drift risk tracked under AIGOV-527 R1 Netero, Ryosuke Yes
CRF-2 P3 Open coderd/database/queries/aicostcontrol.sql:78 GetHighestGroupAIBudgetByUser (+ user_highest_group CTE) dropped the group_id tiebreak and never filters deleted orgs, so a soft-deleted same-name org breaks the total order; LIMIT 1/DISTINCT ON becomes nondeterministic and a dead org's budget can govern R1 Komugi P3, Mafuuu P3, Knuckle P3, Chopper P3, Knov P3, Meruem P3, Razor P3, Hisoka Note Yes
CRF-3 P3 Open coderd/aibridgedserver/cost.go:33 The resolveTokenUsageCost doc drops the third non-error outcome (NULL group), and the inline comment "Spend is still recorded" contradicts the Warn and the skipped IncrementUserAIDailySpend R1 Leorio P3, Hisoka Nit Yes
CRF-4 Note Open coderd/aibridgedserver/aibridgedserver.go:820 The || effectiveGroup.Limit == nil guard is unreachable on this path (ResolveUserAIBudget never returns ok with nil Limit); latent enforcement foot-gun if the resolver is swapped R1 Bisky, Mafu-san, Mafuuu, Pariston, Chopper, Ryosuke, Meruem, Razor Yes
CRF-5 P3 Open enterprise/coderd/aibridge_test.go:3770 The documented cross-org masked-null case (member with spend, null effective_group_id) is reachable via the new fallback CTE but has no test; the replaced NoBudgetWithSpend case left it uncovered R1 Bisky Yes
CRF-6 P4 Open coderd/aibridge/budget/budget.go:90 ResolveUserEffectiveGroup error passthrough and the non-ErrNoRows fallback-query error path are untested R1 Bisky Yes
CRF-7 Nit Open coderd/aibridge/budget/budget.go:33 Limit doc says "tier that produced it" but the field/type is Source R1 Gon Yes
CRF-8 Note Open coderd/aibridge/budget/budget.go:99 Attributing unbudgeted spend to the Everyone group means enabling that group's budget mid-window retroactively counts pre-budget spend; "enforcement is unaffected" understates this R1 Pariston Yes

Contested and acknowledged

None this round.

Round log

Round 1

Netero first pass (1 Note, CRF-1). Mechanical floor clean, panel spawned (16 reviewers: bisky, hisoka, mafu-san, mafuuu, pariston, ging-go, gon, leorio, knuckle, kurapika, komugi, chopper, ryosuke, killua, knov, + wildcards meruem, razor). Multi-domain (Go + SQL + auth/billing). No P0-P2. Centerpiece: CRF-2, a 7-reviewer P3 convergence on lost ORDER BY determinism in the highest-group query, verified against dump.sql:4839 (partial unique index) and the query text. CRF-3 daily-spend skip verified at aibridgedserver.go:382. ging-go and kurapika found no code defects (kurapika confirmed auth gate + tenant boundary correct). Reviewed against 9bd4cf2..a932e25. Event COMMENT.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a clean, well-scoped change. Splitting resolution into ResolveUserAIBudget (enforcement, no fallback) and ResolveUserEffectiveGroup (attribution, with the unlimited Everyone fallback) keeps the blocking path untouched, and reusing the Everyone group (group_id == organization_id) attributes to the org without a schema change. The auth gate on the new query matches its siblings exactly, the tenant boundary in GetGroupMembersAISpend still holds, and test density is high with real DB fixtures that would actually flip if the ordering were wrong. Pariston tried to break the premise and couldn't; kurapika and ging-go found no defects.

Severity counts: 0 P0-P2, 3 P3, 1 P4, 1 Nit, 3 Note.

The one finding worth real attention (CRF-2) is a seven-reviewer convergence: GetHighestGroupAIBudgetByUser dropped its budget.group_id ASC final tiebreak and now leans on organizations.name for determinism, but org names are unique only among non-deleted orgs (idx_organization_name_lower is UNIQUE (lower(name)) WHERE deleted = false) and the query never filters deleted orgs. The guarding test TieBrokenByGroupID was removed in this PR. The fix is nearly free (restore group_id ASC and/or add AND organizations.deleted = false in both the query and the user_highest_group CTE) and restores an invariant the old code held on purpose. This affects enforcement, not just attribution, which is why it leads.

Two description-accuracy notes, not code changes: the description says spend is "now always attributed and tracked," but a user with zero org membership still records spend with a NULL group (logged as unexpected, handled honestly in code) - consider "always attributed when the user has org membership." And CRF-8: enabling an Everyone-group budget mid-window now retroactively counts spend recorded earlier in the window, which "enforcement is unaffected" does not mention.

Hisoka summed the panel's mood: "I came to fight this one. It fought back well... I looked for the kill and it wasn't here. The design held."

CRF-4 (the unreachable Limit == nil guard) drew eight reviewers as a Note: harmless today, but it defends a state ResolveUserAIBudget cannot return, and if a future caller swaps in ResolveUserEffectiveGroup here it would silently disable enforcement for every unbudgeted user. A one-line comment naming the invariant, or dropping the dead operand, would make the boundary explicit.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/queries/aicostcontrol.sql
Comment thread coderd/database/queries/aicostcontrol.sql Outdated
Comment thread coderd/aibridgedserver/cost.go
Comment thread coderd/aibridgedserver/aibridgedserver.go
Comment thread enterprise/coderd/aibridge_test.go
Comment thread coderd/aibridge/budget/budget.go
Comment thread coderd/aibridge/budget/budget.go
Comment thread coderd/aibridge/budget/budget.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion, non-blocking: suggest re-using a single dbtestutil.NewDB instance where possible across tests. Potential follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in a follow-up PR

@ssncferreira
ssncferreira force-pushed the ssncf/aigov-everyone-group-fallback branch from 1f110b2 to d42be83 Compare July 22, 2026 17:48
@ssncferreira
ssncferreira requested a review from johnstcn July 22, 2026 17:51
func (q *querier) GetUserEveryoneFallbackGroup(ctx context.Context, userID uuid.UUID) (uuid.UUID, error) {
if _, err := q.GetUserByID(ctx, userID); err != nil { // AuthZ check
return uuid.Nil, err
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: Would a more targeted authz check be better here? This reads a bunch of fields from the users table when it's not specifically required. Does this end up being called with the user's actor in context, or do we use a special aibridged actor? Again, this could be a potential follow-up but might reduce database load.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point. This actually follows the same pattern as the other 2 queries that we use on user budget resolution: GetUserAIBudgetOverride and GetHighestGroupAIBudgetByUser. So we might need to update all of them.
This is used in 2 paths:

  1. interception cost recording: this is done as the aibridged daemon actor
  2. endpoint GET /users/{user}/ai/spend: this is done as the requesting user's actor

We might be able to do something like:

if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser.WithID(userID).WithOwner(userID.String())); err != nil {
    return uuid.Nil, err
}

The one difference is that we no longer confirm the user exists, so we can't distinguish "user doesn't exist" from "user has no org membership". I think that's fine: it only matters on path 2, and IIRC that endpoint already resolves the user through a middleware before this runs. I'll address it in a follow-up. Thanks for raising it!

@ssncferreira
ssncferreira merged commit c23f2c0 into main Jul 23, 2026
33 of 34 checks passed
@ssncferreira
ssncferreira deleted the ssncf/aigov-everyone-group-fallback branch July 23, 2026 08:26
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants