Skip to content

Tenancy depth: membership-aware group quotas + monthly group spend caps - #62

Closed
hhuuggoo wants to merge 17 commits into
release-2026.08.01from
ctask/tenancy-depth-a08ccceb6f4d6d85cf08b0f85a87a871c1f8285f88d3a2c9ea4a1f796590b81d
Closed

hhuuggoo wants to merge 17 commits into
release-2026.08.01from
ctask/tenancy-depth-a08ccceb6f4d6d85cf08b0f85a87a871c1f8285f88d3a2c9ea4a1f796590b81d

Conversation

@hhuuggoo

@hhuuggoo hhuuggoo commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

What

Tenancy depth (WHITEPAPER-AUDIT row 31): membership-aware group quotas (the four per-minute rates) and group monthly spend caps on the shared gateway path, per Hugo's rulings Q-T1=(b) and Q-T2=(b) (rulings.md, 2026-10-07). The org level and the billing grain are unchanged; money is still written exactly once per window.

  • Migration 0008: billing_event.member_group_ids (evidence, NULL when empty) + group_usage ATTRIBUTION rollup PK(group_id, window_start) — not money; same reconcile contract as rated_usage.
  • Envelope: X-Saturn-Group-Scopes joins the pinned trusted set (13→14, R3 lockstep with the saturn-k8s chart default). Frozen grammar v1;<gid>:<req>,<tot>,<unc>,<gen>,<spend>;... — empty=unlimited, 0=zero cap, plain-decimal spend ≤9 fraction digits, ≤16 entries, <4KiB; malformed/oversize → fail-closed 503 + loud log; strict parse lives at the proxy so a malformed envelope never reaches metering.
  • Metering/rating: membership rides to billing_event; the rater upserts group_usage per (group, hour) from token group_id ∪ member_group_ids while writing the single money row; withheld events excluded.
  • Admission: per-group per-minute windows through the existing Lua machinery (R4 sentinels); monthly spend check evaluated IN Postgres (SUM(group_usage.cost) for the calendar month, cached ~60s per (group, cap), errors not cached so it self-heals, fail-open + throttled loud log per the 2026-09-24 posture). Cap reached → 429 + Retry-After (contractual class). Multi-group is fail-visible: usage counts against EVERY limited group.

Test plan

  • Unit: 19 packages green incl. new envelope/admission/rater/identity/proxy cases (UTC month boundary, sentinels, withheld-excluded, multi-group dedupe, fail-open paths, e2e proxy→drain→rate→spend-verdict chain, real-Valkey group test).
  • Race lane (-race proxy + waker) green.
  • Integration lane (-tags=integration, PHOEBE_TEST_DATABASE_URL) green against Postgres 16 with real 0008 DDL, incl. group-attribution and buildAdmission spend-store wiring tests.

Battery

Tier 2 (trust-boundary + money + schema), 4 rounds: ESCALATE-unsigned (protocol: Tier 2 needs Hugo's snapshot sign-off at merge). 32 findings fix-verified, 2 refuted with executed proof, gate green on the final snapshot. Two design escalations filed as Q-T8 (duplicate header lines → fail closed?) and Q-T9 (startup-DB-outage spend-cap semantics); both are ruling questions, not defects. One bounded low residual: slow-store starvation floor (evidence in the battery report). Full report: agent-wip/battery-reports/battery-tenancy-phoebe.md in saturn-architecture-docs.

Contracts / deploy order

  1. Apply migration 0008 BEFORE deploying this build — the post-0008 drainer poison-drops against a pre-0008 schema.
  2. 0008 rollback is lossy (membership evidence destroyed; month-to-date group spend restarts lower) — documented in migrations/README.md.
  3. Envelope contract is shared with the saturn (stamper) and saturn-k8s (Traefik allowlist + trusted-headers ConfigMap) PRs of the same stream; deploy order among them is runtime-insensitive (absence of the header = unlimited per R4), except rule 1.
  4. Pending stream rulings (do not block merge; they gate follow-ups): Q-T3 dedicated-path coverage (Q-T8/Q-T9 and dedicated-parse questions fold into it), Q-T4 authoring surface, Q-T5–Q-T7 saturn-side escalations.

…p_usage

Membership-aware group quotas (ruled 2026-10-07, Q-T1(b)+Q-T2(b)).
billing_event gains member_group_ids TEXT[] (membership evidence, never part
of the money grain); the new group_usage table is the per-(group, hour)
ATTRIBUTION rollup the rater writes while rating and the admission group
spend check reads. Rollout: code and schema together, in the 0007 order.
…hip rides Identity

The whole group scope set travels one allowlisted header (grammar frozen with
the design). FromRequest reads it only through the R3 trusted-header registry
(pinned fallback 13 → 14, in lockstep with the chart) and extracts the caller's
group list leniently — the strict, fail-closed parse runs at the proxy, and a
malformed envelope never reaches metering.
…pty)

The event gains the caller's group list; the drainer binds it like every
other identity column (nil for none — no empty arrays), making the column
24-wide. Recovery's duplicate-evidence compare switches to canonical JSON:
Event now carries a slice and is no longer comparable with ==.
The single rate statement now writes both rollups from one snapshot: money
into rated_usage (schema, grain, and write count untouched) and attribution
into group_usage — each event attributed to its token's own group plus every
member_group_ids group, deduped per (event, group), withheld (non-money)
events contributing nothing. The same reconcile contract as rated_usage:
in-window group rows the run does not reproduce are deleted. Integration
harnesses apply 0008; the new conformance test pins token-group + membership
+ multi-group + withheld-excluded + re-rate convergence against live
Postgres.
…uotas)

Request gains GroupScopes: per group, the four per-minute windows run as
contract scopes through the existing Lua/counter machinery and settle via the
lease path (R4 sentinels end to end), and the monthly spend cap compares
SUM(cost) over group_usage in Postgres — cached ~60s per (group, cap),
compared in SQL so money never becomes a Go number. Spend >= cap rejects
contractually (a zero cap rejects all paid work); store errors fail open
with a throttled loud log (2026-09-24 posture); a scope without its group id
fails closed like every broken-envelope case.
parseTrustedGroupScopes mirrors parseTrustedRateLimits: behind the
admission-enabled gate, strict on the frozen grammar (v1 prefix, 32-hex gids,
R4 rate sentinels, plain-decimal NUMERIC(20,9) spend caps, <=16 entries,
<4 KiB) — malformed or oversize fails closed 503 with a loud log, the
fail-visible default for multi-group callers. Denials map through the
contractual class (429 + Retry-After); the undeclared-output reservation
clamps to group generated-token limits like the org/owner ones; the parsed
membership list rides the emitted event for the rater.
The monthly spend cap reads group_usage in Postgres (the rater's
attribution rollup), not the admission Valkey. With a DSN the admitter gets
the spend store; without one (serving-only spokes) group rate limits still
enforce and the missing spend check is a loud startup log, never a silent
assumption.
…elope grammar

Spend check: bound each Postgres read on the request path (250ms) and pool
dials (2s) so a blackholed store fails open instead of hanging requests;
compute the month boundary in UTC to match the rater's UTC hour buckets;
expire cached verdicts at the UTC month boundary. Envelope parser: rate
fields must match the frozen grammar (reject signed and zero-padded
values) and spend caps are bounded to the NUMERIC(20,9) integer width.
Cache a short-lived fail-open verdict after a spend-store error so a slow
or blackholed Postgres is not re-queried once per request, collapse
concurrent checks of one (group, cap) into a single in-flight read, and
stop logging a client disconnect as a quota bypass, which consumed the
throttled error slot a real outage needs. Add an integration-tagged test
that runs the spend comparison against live Postgres loaded with the
real migrations, so the zero-cap and spend-equals-cap rules cannot
regress unseen.
all-unlimited group envelopes; recovery: validate member_group_ids

Bound the whole group spend check by a single 250ms budget instead of one
per group, so many uncached capped groups against a slow store wait at most
one budget. Detach the coalesced spend read from the leader's cancellation
so one client's disconnect cannot end the read every waiter shares. Under
admission.enabled=false, engage the admission store only when a group scope
carries a rate limit or spend cap. At the recovery boundary, reject
member_group_ids lists that violate the frozen envelope contract, since
recovered evidence bypasses the proxy's strict parse and the stored list is
what rating attributes into group_usage.
…docs:

correct the 0008 rollout and rollback claims

Extend the group_usage attribution integration test so every per-event
withheld gate is exercised against a shared money grain instead of
vacuously, run the spend integration test under a non-UTC session
timezone, pin the throttled fail-open spend log, disambiguate the group
settlement tests from a kept reservation, and add a real-Valkey group
scope check. Correct the 0008 rollout text (an old rater runs on the new
schema and writes no group_usage) and mark the 0008 rollback lossy:
dropping member_group_ids destroys the membership evidence and
month-to-date group spend restarts lower.
spend path, usage-gate twins, deterministic coalescing

A spend read that begins with almost none of the shared 250ms budget left
and then times out was starved by earlier groups in the same request, not
evidence of an outage: fail it open without the ERROR line and without a
negative-cache entry so the group's next request re-queries the store.
Add an end-to-end test that carries a group envelope from the proxy
through drain and rating to the spend verdict, cover the usage_found and
valid_usage attribution gates with unbillable twins, use grammar-valid
group ids in the withheld-gates fixture, make the hung-store assertion
bound against the spend budget, replace the sleep-based coalescing
rendezvous with a deterministic signal, document the post-0008 drainer
poison-drop against the pre-0008 schema, and correct the window_start
column comment to the UTC boundary the code uses.
@hhuuggoo

hhuuggoo commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #63. Hugo's Q-T3 ruling (2026-10-07, rulings.md row 173) re-cut this build to shared-tier group QUOTAS only — monthly spend caps are deferred ('ignore for now', retrievable). This branch's full implementation (quotas + spend caps, Battery Tier 2-reviewed) stays parked at head edada95 for retrieval when spend caps come back. The quotas-only re-cut (phoebe branch ctask/tenancy-quotas-…, fresh Battery Tier 2 clean) is #63.

@hhuuggoo hhuuggoo closed this Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant