Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
496bf88
migrations: 0008 group scopes — billing_event.member_group_ids + grou…
hhuuggoo Oct 7, 2026
1a17764
identity: X-Saturn-Group-Scopes joins the pinned trusted set; members…
hhuuggoo Oct 7, 2026
911ff22
metering/drain: carry member_group_ids to billing_event (NULL when em…
hhuuggoo Oct 7, 2026
81e0ed5
rating: upsert the group_usage attribution rollup while rating a window
hhuuggoo Oct 7, 2026
c67ecf0
admission: group rate scopes + monthly spend caps (membership-aware q…
hhuuggoo Oct 7, 2026
48b9b62
proxy: parse the group scope envelope and enforce it at admission
hhuuggoo Oct 7, 2026
5452f4c
interceptor: wire the group spend store from DATABASE_URL
hhuuggoo Oct 7, 2026
6889d1c
admission: bound the group spend check; proxy: enforce the frozen env…
hhuuggoo Oct 7, 2026
1976681
admission: collapse the spend-check stampede after a store error
hhuuggoo Oct 7, 2026
2864330
admission: one spend-query budget per request; proxy: skip admission for
hhuuggoo Oct 7, 2026
f24050e
tests: prove the group-attribution gates and spend-check boundaries; …
hhuuggoo Oct 7, 2026
b3a7924
admission: a starved spend read is not a store outage; tests: e2e group
hhuuggoo Oct 7, 2026
f784bae
proxy: pin the group generated-token cap in the undeclared-max_tokens…
hhuuggoo Oct 7, 2026
accf4d6
interceptor: test the buildAdmission DATABASE_URL spend-store wiring
hhuuggoo Oct 7, 2026
3b0dbe8
admission: rename cap parameter to spendCap (revive redefines-builtin…
hhuuggoo Oct 7, 2026
354034c
admission: rename test cap locals to spendCap (revive redefines-built…
hhuuggoo Oct 7, 2026
edada95
admission: rename remaining cap identifiers in integration tests (rev…
hhuuggoo Oct 7, 2026
c173c07
migrations: drop 0008 (spend attribution parked)
hhuuggoo Oct 7, 2026
6e59ed3
metering/drain/identity/recovery: revert member_group_ids (parked)
hhuuggoo Oct 7, 2026
0f364dd
proxy: pin the envelope spend field empty (spend caps parked)
hhuuggoo Oct 7, 2026
a479097
rating: revert the group_usage rollup (parked)
hhuuggoo Oct 7, 2026
76a8944
interceptor: drop the group spend-store wiring (parked)
hhuuggoo Oct 7, 2026
373b146
admission: drop the spend check (parked)
hhuuggoo Oct 7, 2026
94cfd26
proxy: pin the 4 KiB envelope size bound with an oversize parse case
hhuuggoo Oct 7, 2026
2c503b3
admission: test a group total-prompt window through Admit
hhuuggoo Oct 7, 2026
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 cmd/interceptor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func main() {

// R3 trusted-header registry: parse PHOEBE_TRUSTED_HEADERS (rendered
// from the phoebe chart's ConfigMap) into the active set the parser's
// envelope reads resolve through. The pinned 13 already govern from
// envelope reads resolve through. The pinned 14 already govern from
// package init; this engages the runtime config once, at startup, and
// warns loudly if the chart render was empty/malformed.
identity.LoadTrustedHeaders(log)
Expand Down
37 changes: 37 additions & 0 deletions internal/admission/admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ type Request struct {
Adapter bool
OrganizationLimits RateLimits
OwnerLimits RateLimits
// GroupScopes is the parsed membership-aware group quota envelope
// (X-Saturn-Group-Scopes, ruled 2026-10-07): one entry per group the
// caller belongs to that carries limits. Each entry's Limits enforce the
// four per-minute rate windows as a contract scope (the same Lua/counter
// machinery as the contract_owner scope, settling through the same
// lease/CompleteUsage path). Empty when the envelope carried no group
// (the common case).
GroupScopes []GroupScope
}

// GroupScope is one group's quota contract within a Request: the group's id
// and its four per-minute rate limits (R4 sentinels: nil unlimited, 0 zero
// cap). The envelope's fifth field is reserved for the deferred monthly spend
// cap; the proxy rejects any envelope that carries a non-empty value there,
// so a GroupScope never holds one in this build.
type GroupScope struct {
GroupID string
Limits RateLimits
}

// RateLimits is the authenticated customer contract. R4 sentinel semantics
Expand Down Expand Up @@ -199,6 +217,14 @@ func (a *RedisAdmitter) Admit(ctx context.Context, req Request) (*Lease, error)
if req.OwnerLimits.Any() && req.Owner == "" {
return nil, fmt.Errorf("%w: missing trusted owner identity", ErrInvalidIdentity)
}
for _, gs := range req.GroupScopes {
// A group scope carrying any limit must name its group: an anonymous
// quota is not a quota, it is a broken envelope (fail closed, like
// every other structural violation).
if gs.GroupID == "" && gs.Limits.Any() {
return nil, fmt.Errorf("%w: group scope carries limits without a group id", ErrInvalidIdentity)
}
}
if req.PromptBytes < 0 || req.EstimatedInputTokens <= 0 || req.ReservedOutputTokens <= 0 {
return nil, &Rejected{Scope: "request", Dimension: "work estimate", RetryAfter: time.Second}
}
Expand Down Expand Up @@ -385,6 +411,17 @@ func (a *RedisAdmitter) scopes(r Request) []scope {
if r.OwnerLimits.Any() {
out = append(out, makeContractScope("contract_owner", r.Owner, r.OwnerLimits))
}
// Group rate scopes: one contract scope per group that carries any rate
// limit, same Lua/counter machinery and wireLimit sentinels as the org/
// owner contract scopes, settling through the same lease/CompleteUsage
// path (the lease record holds this scope like any other, and finishScript
// charges its windows). The scope NAME carries the group id so a rejection
// names the team; the scope id digests it, so groups never collide.
for _, gs := range r.GroupScopes {
if gs.Limits.Any() {
out = append(out, makeContractScope("group:"+gs.GroupID, gs.GroupID, gs.Limits))
}
}
return out
}

Expand Down
227 changes: 227 additions & 0 deletions internal/admission/group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package admission

import (
"context"
"errors"
"testing"

"github.com/saturncloud/phoebe/internal/config"
)

const testGID = "a1b2c3d4e5f60718293a4b5c6d7e8f90"

// groupScope builds one GroupScope. rate of nil leaves every rate unset
// (unlimited); a non-nil rate installs all four dimensions to the same value.
func groupScope(gid string, rate *int64) GroupScope {
gs := GroupScope{GroupID: gid}
if rate != nil {
gs.Limits = RateLimits{Requests: rate, TotalPromptTokens: rate,
UncachedPromptTokens: rate, GeneratedTokens: rate}
}
return gs
}

func groupRequest(org, model string, scopes ...GroupScope) Request {
r := request(org, model)
r.GroupScopes = scopes
return r
}

// TestGroupRateLimitsEnforcedLikeContractScope: a group's per-minute windows
// are the same Lua/counter machinery as the contract scopes — a burst up to
// the cap admits, the next request is a contractual rejection (the proxy maps
// that to 429 + Retry-After).
func TestGroupRateLimitsEnforcedLikeContractScope(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
// Only the requests window is capped, so the request's own estimates
// (the helper's 10-in/20-out shape) can never trip the unsatisfiable
// check: the third request is rejected by the window counter itself.
reqCap := int64(2)
scope := GroupScope{GroupID: testGID, Limits: RateLimits{Requests: &reqCap}}

for i := 0; i < 2; i++ {
lease, err := a.Admit(context.Background(), groupRequest("org-g", "m", scope))
if err != nil {
t.Fatalf("admit %d: %v, want admitted", i+1, err)
}
_ = lease.Complete(context.Background(), 0)
}
_, err := a.Admit(context.Background(), groupRequest("org-g", "m", scope))
var rejected *Rejected
if !errors.As(err, &rejected) {
t.Fatalf("third admit: %v, want a contract rejection", err)
}
if !rejected.Contractual || rejected.RetryAfter <= 0 {
t.Fatalf("rejection = %+v, want Contractual with a Retry-After (429 mapping)", rejected)
}
if rejected.Scope != "group:"+testGID {
t.Fatalf("rejection scope = %q, want the group id named", rejected.Scope)
}
}

// TestGroupTotalPromptTokensWindowEnforced: a group scope may cap only the
// total-prompt window, leaving the other rate fields nil (unlimited). Unlike
// the request-count window, token windows charge actual usage at settlement,
// so each request settles its 10-token input estimate: two requests fill a
// 20-token cap and the next reservation is a contractual rejection naming
// the group scope.
func TestGroupTotalPromptTokensWindowEnforced(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
promptCap := int64(20)
r := groupRequest("org-p", "m", GroupScope{GroupID: testGID,
Limits: RateLimits{TotalPromptTokens: &promptCap}})

for i := 0; i < 2; i++ {
lease, err := a.Admit(context.Background(), r)
if err != nil {
t.Fatalf("admit %d: %v, want admitted", i+1, err)
}
if err := lease.CompleteUsage(context.Background(), Usage{TotalPromptTokens: 10}); err != nil {
t.Fatalf("CompleteUsage %d: %v", i+1, err)
}
}

_, err := a.Admit(context.Background(), r)
var rejected *Rejected
if !errors.As(err, &rejected) {
t.Fatalf("third admit: %v, want a contract rejection", err)
}
if !rejected.Contractual || rejected.RetryAfter <= 0 {
t.Fatalf("rejection = %+v, want Contractual with a Retry-After (429 mapping)", rejected)
}
if rejected.Scope != "group:"+testGID {
t.Fatalf("rejection scope = %q, want the group id named", rejected.Scope)
}
if rejected.Dimension != "total_prompt_tokens" {
t.Fatalf("rejection dimension = %q, want the total-prompt window", rejected.Dimension)
}
}

// TestGroupRateZeroCapBlocksEveryRequest: an explicit 0 is a zero cap, not
// unlimited — the same R4 sentinel as every other contract scope.
func TestGroupRateZeroCapBlocksEveryRequest(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
zero := int64(0)
_, err := a.Admit(context.Background(), groupRequest("org-z", "m",
groupScope(testGID, &zero)))
var rejected *Rejected
if !errors.As(err, &rejected) || !rejected.Contractual {
t.Fatalf("zero-capped group admit: %v, want a contractual rejection", err)
}
}

// TestGroupRateNilLimitIsUnlimited: a group scope whose rate fields are all
// nil contributes no contract scope (unlimited), so requests flow freely.
func TestGroupRateNilLimitIsUnlimited(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
for i := 0; i < 3; i++ {
lease, err := a.Admit(context.Background(), groupRequest("org-u", "m",
groupScope(testGID, nil)))
if err != nil {
t.Fatalf("admit %d: %v, want admitted (nil limits are unlimited)", i+1, err)
}
_ = lease.Complete(context.Background(), 0)
}
}

// TestGroupScopeMissingGroupIDFailsClosed: a scope carrying limits without a
// group id is a broken envelope, and broken trusted identity fails closed —
// distinct from the store-outage bypass.
func TestGroupScopeMissingGroupIDFailsClosed(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
reqCap := int64(10)
_, err := a.Admit(context.Background(), groupRequest("org-i", "m",
GroupScope{Limits: RateLimits{Requests: &reqCap}}))
if !errors.Is(err, ErrInvalidIdentity) {
t.Fatalf("anonymous group scope: %v, want ErrInvalidIdentity (fail closed)", err)
}

// A scope with nothing to enforce and no id is inert, not an error.
lease, err := a.Admit(context.Background(), groupRequest("org-i", "m", GroupScope{}))
if err != nil {
t.Fatalf("inert anonymous scope: %v, want admission", err)
}
_ = lease.Complete(context.Background(), 0)
}

// TestGroupScopesSettleThroughCompleteUsage: the group contract scope rides
// the lease like any other scope — CompleteUsage settles it through the same
// finishScript path, charging the window the engine-reported tokens (not the
// reservation). Generated cap 50: settle 30, then 20 — both admit; a third
// reservation that would push the window past 50 is rejected.
func TestGroupScopesSettleThroughCompleteUsage(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
genCap := int64(50)
r := groupRequest("org-l", "m", GroupScope{GroupID: testGID,
Limits: RateLimits{GeneratedTokens: &genCap}})

lease, err := a.Admit(context.Background(), r)
if err != nil {
t.Fatalf("first admit: %v", err)
}
if err := lease.Complete(context.Background(), 30); err != nil {
t.Fatalf("first Complete: %v", err)
}

lease2, err := a.Admit(context.Background(), r)
if err != nil {
t.Fatalf("second admit (window at 30/50): %v, want admitted", err)
}
if err := lease2.Complete(context.Background(), 20); err != nil {
t.Fatalf("second Complete: %v", err)
}

_, err = a.Admit(context.Background(), r)
var rejected *Rejected
if !errors.As(err, &rejected) || !rejected.Contractual {
t.Fatalf("third admit (window at 50/50): %v, want a contractual rejection", err)
}
}

// TestGroupScopesSettleZeroUsageRefundsReservation: the settlement test above
// cannot tell settlement from a reservation left charged (each request
// reserves 20 output tokens, so an unsettled window would also read 20, 40,
// 60). Here every request completes with zero generated tokens: against a cap
// of 50, five requests admit only if each settlement refunds its 20-token
// reservation — without settlement the third would be rejected at 60/50.
func TestGroupScopesSettleZeroUsageRefundsReservation(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
genCap := int64(50)
r := groupRequest("org-z0", "m", GroupScope{GroupID: testGID,
Limits: RateLimits{GeneratedTokens: &genCap}})

for i := 0; i < 5; i++ {
lease, err := a.Admit(context.Background(), r)
if err != nil {
t.Fatalf("admit %d: %v, want admitted (each zero-usage Complete refunds the reservation)", i+1, err)
}
if err := lease.Complete(context.Background(), 0); err != nil {
t.Fatalf("Complete %d: %v", i+1, err)
}
}
}

// TestGroupScopesSettleChargesActualUsage: settling 45 generated tokens leaves
// the window at 45/50, so the next 20-token reservation is rejected — even
// though an unsettled window (holding only the first 20-token reservation)
// would have fit it at 40/50.
func TestGroupScopesSettleChargesActualUsage(t *testing.T) {
a, _ := testAdmitter(t, config.AdmissionSettings{})
genCap := int64(50)
r := groupRequest("org-l45", "m", GroupScope{GroupID: testGID,
Limits: RateLimits{GeneratedTokens: &genCap}})

lease, err := a.Admit(context.Background(), r)
if err != nil {
t.Fatalf("first admit: %v", err)
}
if err := lease.Complete(context.Background(), 45); err != nil {
t.Fatalf("Complete: %v", err)
}

_, err = a.Admit(context.Background(), r)
var rejected *Rejected
if !errors.As(err, &rejected) || !rejected.Contractual || rejected.Scope != "group:"+testGID {
t.Fatalf("admit after settling 45/50: %v, want a contractual group rejection", err)
}
}
75 changes: 75 additions & 0 deletions internal/admission/real_valkey_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,78 @@ func TestRealValkeyContractOnlyBurst(t *testing.T) {
})
}
}

// TestRealValkeyGroupScopes runs the group fan-out through real Valkey's Lua:
// one request carrying 16 rate-limited group scopes (the envelope maximum)
// admits and settles, each group's window is charged the settled usage rather
// than the reservation, and a zero-capped group is a contractual rejection.
func TestRealValkeyGroupScopes(t *testing.T) {
addr := os.Getenv("PHOEBE_TEST_ADMISSION_VALKEY_ADDR")
if addr == "" {
t.Skip("PHOEBE_TEST_ADMISSION_VALKEY_ADDR not set; skipping real-Valkey group scopes")
}
ctx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
defer cancel()
client := redis.NewClient(&redis.Options{Addr: addr})
if err := client.Ping(ctx).Err(); err != nil {
t.Fatalf("ping real Valkey: %v", err)
}
cfg := config.AdmissionSettings{
KeyPrefix: fmt.Sprintf("phoebe-admission-groups-%d", time.Now().UnixNano()),
LeaseTTL: time.Second,
Platform: limits(64),
}
a := New(client, cfg)
t.Cleanup(func() {
_ = client.Del(context.Background(), a.counters, a.leases, a.expiries, a.windowExpiries).Err()
_ = client.Close()
})

// The windows are fixed per minute; start clear of a boundary so the
// settle and the per-group checks below land in the same window.
if s := time.Now().Second(); s >= 50 {
time.Sleep(time.Duration(61-s) * time.Second)
}

genCap := int64(50)
scopes := make([]GroupScope, 16)
for i := range scopes {
scopes[i] = GroupScope{GroupID: fmt.Sprintf("%032x", i+1),
Limits: RateLimits{GeneratedTokens: &genCap}}
}
r := request("org-g", "model")
r.GroupScopes = scopes

lease, err := a.Admit(ctx, r)
if err != nil {
t.Fatalf("admit 16-group request: %v", err)
}
// Settle 45 generated tokens: every group window now reads 45/50. A window
// left holding only the 20-token reservation would read 20/50 instead.
if err := lease.Complete(ctx, 45); err != nil {
t.Fatalf("settle 16-group request: %v", err)
}

// Each group alone: a 6-token reservation fits an unsettled 20/50 window
// but not a settled 45/50 one, so a rejection naming that group proves its
// own window settled.
for _, gs := range scopes {
probe := request("org-g", "model")
probe.ReservedOutputTokens = 6
probe.GroupScopes = []GroupScope{gs}
_, err := a.Admit(ctx, probe)
rejected, ok := err.(*Rejected)
if !ok || !rejected.Contractual || rejected.Scope != "group:"+gs.GroupID {
t.Fatalf("probe for group %s after settling 45/50 = %T %v, want a contractual rejection naming the group", gs.GroupID, err, err)
}
}

zero := int64(0)
zr := request("org-g", "model")
zr.GroupScopes = []GroupScope{{GroupID: fmt.Sprintf("%032x", 99),
Limits: RateLimits{Requests: &zero}}}
_, err = a.Admit(ctx, zr)
if rejected, ok := err.(*Rejected); !ok || !rejected.Contractual {
t.Fatalf("zero-capped group admit = %T %v, want a contractual rejection", err, err)
}
}
Loading
Loading