[feat] Extend access controls and billing settings#4330
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR generalizes plan/role definitions from closed Python enums into runtime-configurable env vars (AGENTA_ACCESS_*, AGENTA_BILLING_*), splits the previous /admin/billing/usage/flush span retention endpoint into independent /admin/spans/flush and /admin/events/flush admin routers (each with its own service, DAO, cron, and Redis lock), and updates the frontend to treat plan slugs as plain strings while keeping a DefaultPlan enum for code-side conditionals. Backend Plan/WorkspaceRole enums are largely replaced with string slugs validated against the effective access-controls catalog at startup.
Changes:
- New
AccessControls/BillingSettingsenv layers parsed at startup with strict cross-reference validation; legacySTRIPE_PRICINGremoved and migration script provided. - New
Counter.EVENTSplusEventsService/EventsDAO/EventsRouter/SpansRouterandevents.shcron;BillingRouterno longer owns retention. - Frontend
Planwidened tostringwithDefaultPlanenum retained for known constants; updates to billing UI, banners, anduseEntitlements.
Reviewed changes
Copilot reviewed 67 out of 72 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| api/oss/src/utils/env.py | Adds AccessControls + BillingSettings env models with JSON loaders; drops StripeConfig.pricing. |
| api/ee/src/core/entitlements/types.py | Renames Plan→DefaultPlan, adds DefaultRole/Counter.EVENTS/Period; renames CATALOG/ENTITLEMENTS→DEFAULT_*. |
| api/ee/src/core/subscriptions/{settings,service,types}.py | New billing settings module with strict validation; service uses dynamic free/trial accessors; types use string plans. |
| api/ee/src/core/entitlements/{service,controls(*not in diff)}.py | Entitlements service consumes get_plan_entitlements. |
| api/ee/src/core/{events,tracing}/service.py | New EventsService.flush_events; TracingService.flush_spans iterates dynamic plans. |
| api/ee/src/dbs/postgres/events/{init,dao}.py | New EE retention DAO independent from OSS events DAO. |
| api/ee/src/apis/fastapi/{spans,events}/router.py | New independent admin routers replacing /admin/billing/usage/flush. |
| api/ee/src/apis/fastapi/billing/router.py | Drops tracing_service dep + flush endpoint; uses dynamic catalog/pricing/free-plan accessors. |
| api/ee/src/main.py | Wires new spans/events services and routers. |
| api/ee/src/services/{throttling_service,workspace_manager,db_manager_ee,converters,admin_manager}.py | Switch to controls accessors and string slugs. |
| api/ee/src/utils/{permissions,entitlements}.py | Resolve role permissions and entitlements via controls module. |
| api/ee/src/routers/workspace_router.py | Validates assigned role against effective workspace catalog. |
| api/ee/src/models/{db_models, api/api_models, api/workspace_models}.py | Org-member default role member→viewer; widens role types to str. |
| api/ee/src/core/workspaces/types.py | WorkspacePermission.role_name: str. |
| api/oss/src/core/{accounts,auth}/service.py, api/oss/src/routers/workspace_router.py | Remove enum coupling; validate plan/role via EE controls. |
| api/ee/src/dbs/postgres/subscriptions/mappings.py | Drop Plan enum coercion. |
| api/ee/databases/postgres/migrations/.../*.py | Inline literal FREE_PLAN constants; new migration to unify member→viewer. |
| api/ee/src/crons/{spans.sh,events.sh,events.txt}, api/ee/docker/Dockerfile.{dev,gh}, hosting/docker-compose/ee/* | Cron + image wiring for new events flush job and EE env examples. |
| api/ee/tests/pytest/unit/test_{access_controls,billing_settings,billing_router,controls_env_override,events_retention,admin_retention_routers}.py | New/updated tests for parsers, env wiring, retention services, and admin routers. |
| docs/docs/self-host/{02-configuration,04-dynamic-access-controls,05-dynamic-billing-settings}.mdx | Operator-facing documentation for the new env surface. |
| docs/designs/dynamic-access-and-billing/{research,gap,proposal,tasks,findings,migrate_stripe_pricing.py}.* | Design folder + legacy STRIPE_PRICING converter. |
| docs/designs/data-retention/*, docs/design/ee-self-hosting/research.md, docs/openapi-cleanup/endpoints.md | Updated to reference the split admin endpoints. |
| web/oss/src/lib/Types.ts, web/oss/src/lib/helpers/useEntitlements.ts | Plan enum renamed Plan→DefaultPlan; runtime plan typed as string. |
| web/ee/src/services/billing/types.d.ts, web/ee/src/components/SidebarBanners/state/atoms.ts, web/ee/src/components/pages/settings/Billing/* | Frontend updated to use DefaultPlan constants and string plan slugs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
mmabrouk
left a comment
There was a problem hiding this comment.
Thank you @jp-agenta
- The
projectcomment from yesterday is still unadressed - Codex returned a reasonably correct issue. I added it as a comment. Worth investigating
Other than that lgtm! Same comment as yesterday, having a comprehensive evaluation here is important. Especially for self-hosted ee, since upgrades there are not automatic.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 90 out of 106 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (4)
docs/designs/dynamic-access-and-billing/summary.md:1
- The PR description and several places in the design docs still advertise
AGENTA_BILLING_TRIAL_PLANandAGENTA_BILLING_TRIAL_DAYSas added env vars (e.g. the "Env vars" section of the PR body andresearch.mdlines 135–137). The shipped code (api/oss/src/utils/env.pyBillingSettings,summary.md, andmigrate_stripe_pricing.py) collapses the trial config into a per-entry{trial: N}marker onAGENTA_BILLING_PRICINGandBillingSettingsno longer carriestrial_plan/trial_daysfields. Please update the PR description andresearch.mdto match the shipped flat-pricing shape so operators don't try to set the removed env vars.
api/ee/src/services/converters.py:1 - When
\"*\"is present alongside other slugs, the wildcard branch discards every non-wildcard slug and returns the full Permission enum. That's fine for the platform-synthesizedowner(which is exactly[\"*\"]), but env-overridable roles viaAGENTA_ACCESS_ROLES/AGENTA_ACCESS_ROLES_OVERLAYcould legitimately combine\"*\"with extras, or include a custom slug not inPermission. In the latter case Pydantic will reject the wholeWorkspacePermission.permissionslist because the slug isn't an enum member, producing a 500 at the API boundary. Consider filtering non-Permission slugs out (with a warning) before returning, and merging the expansion of\"*\"with any other valid Permission slugs in the input.
api/oss/src/utils/env.py:1 - These call
_load_json_env_dict(...)at class-definition time (Pydantic field defaults are evaluated once when the class body executes). That means anyValueErrorraised by the loader becomes an import-time failure with a stack trace originating in module import, rather than a clean Pydantic validation error atEnvironSettings()instantiation, and the resulting dict is a mutable singleton shared across any future instances. Consider usingdefault_factory=lambda: _load_json_env_dict(\"AGENTA_ACCESS_PLANS\")(and similar for the other JSON fields) so loading happens on instantiation and each instance gets its own object.
api/ee/src/services/throttling_service.py:1 - Module-level mutable warned-once flag is read/written without a lock from
throttling_middleware, which can run concurrently across asyncio tasks and (under multi-worker deployments) processes. The race is benign for a warning-suppression flag — worst case the warning logs a few extra times — but if this pattern is replicated, consider usingfunctools.lru_cache-on-a-no-arg helper or a logger filter, which makes the once-only intent explicit and process-safe.
What this unlocks
This PR turns the previously code-locked plan/role/catalog/pricing surface into an env-driven configuration layer. Operators may set
AGENTA_ACCESS_PLANS,AGENTA_ACCESS_ROLES,AGENTA_ACCESS_ROLES_OVERLAY,AGENTA_ACCESS_DEFAULT_PLAN,AGENTA_ACCESS_DEFAULT_PLAN_OVERLAY,AGENTA_BILLING_CATALOG, andAGENTA_BILLING_PRICINGat deployment time, restart the API and worker processes, and the new shape is the effective shape — no code change, no rebuild. When any of the JSON env vars are absent the system falls back to code defaults, so existing deployments need no operator action.The immediate capabilities this lights up are: define a custom plan with a custom entitlement profile (new counter limits, new gauges, new throttle buckets, new flags), tweak a single quota on an existing plan without restating the rest (
AGENTA_ACCESS_DEFAULT_PLAN_OVERLAYmerges field-by-field into whichever planAGENTA_ACCESS_DEFAULT_PLANpoints to), add a custom role at the project (and workspace, by symmetry) scope without restating the platform-managed minima or the default workspace roles (AGENTA_ACCESS_ROLES_OVERLAY), mark the free and trial plan slugs inline in pricing ({free: true}/{trial: N}markers on the pricing entries themselves — no cross-env-var consistency burden), and separate display catalog from purchase pricing so the pricing modal and the Stripe line-item / per-meter-price configuration evolve independently. The closedPlanenum is gone;subscriptions.planis a plainstr, and the auth-side role serialization widened tostrtoo — env-defined custom roles flow cleanly throughWorkspacePermission.role_nameandInviteRequest.roleswithout enum-coercion failures.A second hard cut shipped alongside: events retention split out of billing. The old
POST /admin/billing/usage/flush(spans-only) is gone; spans now flush atPOST /admin/spans/flush(croncrons/spans.shat0,30 * * * *, lock namespacespans:flush) and events flush atPOST /admin/events/flush(croncrons/events.shat7,37 * * * *, lock namespaceevents:flush). They share nothing — separate DAOs, services, routers, cron files, Redis locks — so the two flushes can run concurrently and a failure in one never blocks the other.Counter.EVENTS_INGESTEDwas added to the entitlement system as a retention-only counter (no write-path adjusts a meter row for it, no entry in themeters_typePostgres enum, deliberately not inREPORTS); the events flush job walks the effective plan map and respects each plan'sCounter.EVENTS_INGESTED.retention. Per-plan defaults align with each plan'sCounter.TRACES_INGESTEDretention:Quota(period=Period.MONTHLY, retention=Retention.MONTHLY)on Hobby,QUARTERLYon Pro,YEARLYon Business, andretention=None(unlimited) on Agenta and Self-hosted Enterprise.What changed under the hood
env vars layout.
api/oss/src/utils/env.pygains two sub-models onEnvironSettings:AccessControls(plans,roles,roles_overlay,default_plan,default_plan_overlay) andBillingSettings(catalog,pricing). The JSON-bearing fields are decoded into typed Python objects at startup via small_load_json_env_*helpers, so downstream modules consume already-parsed dicts / lists and never re-parse strings.AGENTA_ACCESS_DEFAULT_PLANis the canonical default-plan name; legacyAGENTA_DEFAULT_PLANis honored as a read-through fallback so existing deployments are not forced to rename their env var. The trial-flow config previously carried byAGENTA_BILLING_TRIAL_PLAN/AGENTA_BILLING_TRIAL_DAYSis now inline on the pricing entry as a{trial: N}marker (positive integer days) — there can be at most one trial entry across the whole pricing dict, andBillingSettingsno longer carriestrial_plan/trial_daysfields.controls.py— the access-controls builder. A new moduleapi/ee/src/core/entitlements/controls.pyowns plans + roles._build_controls()runs once at import time: it parsesAGENTA_ACCESS_PLANSagainst_PlanOverride(Pydantic,extra="forbid") or falls back toDEFAULT_ENTITLEMENTSkeyed by theDefaultPlanenum, appliesAGENTA_ACCESS_DEFAULT_PLAN_OVERLAYto the resolved default plan via_merge_quota/_merge_throttle, parsesAGENTA_ACCESS_ROLESand the role catalog falls back to_default_roles()(organization gets minima only, workspace and project get minima + default extras likeadmin/developer/editor/annotator), appliesAGENTA_ACCESS_ROLES_OVERLAYto both workspace and project scopes, and hashes the resulting controls into a 12-character SHA stamp. The public surface —get_plans(),get_plan(slug),get_plan_entitlements(slug),get_plan_description(slug),get_roles(scope),get_role(scope, slug),get_role_permissions(scope, slug),get_role_description(scope, slug),get_controls_hash()— returns the frozen effective state and is what every runtime caller uses. At startup the module logs[access-controls] plans=defaults|env roles=defaults|env plan_overlay=none|env→<slug> roles_overlay=none|env hash=<12char>so multi-worker deployments can grep across logs to verify every process loaded the same config.settings.py— the billing-settings builder.api/ee/src/core/subscriptions/settings.pyparsesAGENTA_BILLING_CATALOG(list of_CatalogEntryPydantic models withextra="allow"so operators can add display-only fields the frontend renders;type ∈ {"standard", "custom"}is enforced) andAGENTA_BILLING_PRICING(object keyed by plan slug — flat shape{slug: {free?, trial?, <stripe_slot>: {price, quantity?}}}that mirrors the originalSTRIPE_PRICINGplus the two reserved markers). Reserved keys arefree(bool) andtrial(positive int days); every other key on a pricing entry is a Stripe-side slot name owned by the operator (the same names they used on the Stripe dashboard — typically"traces"and"users") and is not validated against an internal enum. Cross-cutting validation runs in_build_settings(): every catalogplanslug must exist in the effective plan set, every pricing slug must exist in the effective plan set, at most one entry may carry"free": true, at most one entry may carry"trial": N, the free-plan fallback must be resolvable, andAGENTA_ACCESS_DEFAULT_PLAN(when set) must be in the effective plan set. Accessorsget_catalog(),get_catalog_plan(slug),get_pricing(),get_pricing_plan(slug),get_stripe_line_items(slug),get_stripe_meter_price(plan, meter),get_free_plan(),get_trial_plan(),get_trial_days(),trial_enabled()are how the billing router, the meter reporting job, the subscription service, and the signup flow read the effective settings.[billing-settings] catalog=defaults|env pricing=defaults|env free_plan=<slug>|None trial=<slug>/<n>d|disabledis logged at startup.Stripe meter name mapping. Internal
Counter/Gaugeslugs (code identifiers) and Stripe-side meter event names (external configuration the operator owns on the Stripe dashboard) are independent surfaces.api/ee/src/core/entitlements/types.pycarries a single source of truth at module scope:STRIPE_METER_NAMES: dict[str, str] = {Counter.TRACES_INGESTED.value: "traces", Gauge.USERS.value: "users"}. The runtime inmeters/service.pyusesSTRIPE_METER_NAMES.get(meter.key.value)for both thestripe.billing.MeterEvent.create(event_name=...)argument and the per-meter price-id lookup; meters not in the map fall through the existing skipped-meter log path with a clear message. The pricing-config slot names inAGENTA_BILLING_PRICINGagree with the Stripe-side names (operator-owned), so an internal rename ofCounter.TRACES_INGESTEDdoes not require a coordinated Stripe-dashboard change or an env-var rewrite on every deployment. Adding a new Stripe-reportable counter or gauge requires exactly two edits: add theCounter/Gaugemember toREPORTS, and add the row toSTRIPE_METER_NAMES.Default-plan overlay.
AGENTA_ACCESS_DEFAULT_PLAN_OVERLAYlets self-hosted operators tweak individual entitlement values on the default plan without restating the rest. The shape mirrors_PlanOverride(description,flags,counters,gauges,throttles) with one divergence:throttlesis a map keyed by category slug instead of a list, so per-category patches don't require restating the whole throttle list. Merge semantics —descriptionreplaces,flagsper-key replace,counters/gaugesfield-merge inside each quota (overlay-set fields replace, omitted fields keep the base; passnullto clear), andthrottles[category]looks up the existing single-category throttle on the base plan and field-merges itsbucket. Operators who need multi-category or endpoint-keyed throttle changes useAGENTA_ACCESS_PLANSinstead. Resolution happens after the base plan map is built but before the public dicts are frozen, so all downstream accessors see the merged state.Roles overlay.
AGENTA_ACCESS_ROLES_OVERLAYis the symmetric overlay for the role catalog. Today only theprojecttop-level key is accepted; the patch is applied to both theworkspaceandprojectscopes because they share the same default role set in the code defaults. Ifworkspaceororganizationappears as a key, startup fails — silent ignore would mislead operators. Per role slug: if the slug exists on the scope, the patch is a per-field replace (permissionsswaps the array,descriptionswaps the string, fields not set are preserved); if the slug does not exist, the entry is appended as a new role and bothpermissionsanddescriptionmust be supplied.ownerandviewerminima are reserved and cannot be patched — they are platform-managed and synthesized fresh after every override.Catalog vs. pricing. Splitting them is the central design choice:
AGENTA_BILLING_CATALOGis the user-facing pricing-modal display (title, description, type, features, optional displaypriceandretentionfor marketing copy), andAGENTA_BILLING_PRICINGis the Stripe wiring (line items for checkout / subscription create, per-meter price IDs for usage reporting, plus thefree/trialmarkers). Catalog entries without aplanfield are exempt from the effective-plan-set cross-check (contact-sales tiers like Enterprise have no slug). Stripe line items are optional on a per-plan basis — custom and self-hosted plans aren't directly purchasable, and paid-checkout flows fail with a clear"Plan 'X' is not available for purchase (no Stripe line items configured)"instead of silently mis-charging. Only counters in theREPORTSallowlist (todaytraces_ingestedandusers) actually get pushed to Stripe; declaring price IDs for other slot names is accepted by the parser but those meters are not reported.Onboarding plan resolution.
get_default_plan()readsenv.access_controls.default_planfirst (with the legacyAGENTA_DEFAULT_PLANas fallback), and falls back toDefaultPlan.CLOUD_V0_HOBBYwhen Stripe is enabled andDefaultPlan.SELF_HOSTED_ENTERPRISEwhen Stripe is disabled.get_free_plan()returns whichever pricing entry is marked"free": true, falling back toDefaultPlan.CLOUD_V0_HOBBYwhen no env-driven free plan is defined (and only if that slug is in the effective plan set — operators who restrictAGENTA_ACCESS_PLANSto a slug set withoutcloud_v0_hobbymust mark one pricing entry as"free": trueor startup fails).trial_enabled()is true when some pricing entry carries a"trial": Nmarker (the single trial entry's slug + day count driveget_trial_plan()andget_trial_days()). Behavior: Stripe-enabled + trial-configured → reverse-trial flow on the trial plan, then auto-downgrade to free; Stripe-enabled + no trial → direct onboarding on the free plan; Stripe-disabled → direct onboarding onget_default_plan().Runtime refactor. Direct imports of
CATALOG,ENTITLEMENTS,FREE_PLAN,REVERSE_TRIAL_PLAN,REVERSE_TRIAL_DAYSare gone from runtime code. The closedPlanenum is gone;SubscriptionDTO.planisOptional[str], response models usestr, role-validation usescontrols.get_role(scope, slug)instead ofWorkspaceRole(value). Constants inentitlements/types.pywere renamed toDEFAULT_CATALOG/DEFAULT_ENTITLEMENTSto signal their fallback role, andDefaultPlan/DefaultRoleenums survive as code-default seeds used only for the fallback and for type-safe conditional checks against well-known slugs. The throttle middleware (api/ee/src/services/throttling_service.py) iteratesget_plans()and falls back to the free plan's throttle bucket when an organization is on an unknown plan, so a misconfigured / orphaned subscription still gets rate-limited instead of bypassing throttling entirely. The tracing-flush job and the events-flush job both walkget_plans()and respect each plan'sCounter.TRACES_INGESTED.retention/Counter.EVENTS_INGESTED.retentionper the new dynamic plan map.Permission.default_permissions(role)callers were replaced withget_role_permissions(scope, role)andWorkspaceRole.get_description(role)callers withget_role_description(scope, role)so the resolution path goes through the effective catalog, not the closed enum.Quota hardening.
Quota,Probe,Bucket, andThrottlePydantic models all declaremodel_config = ConfigDict(extra="forbid")so operator typos inAGENTA_ACCESS_PLANSorAGENTA_ACCESS_DEFAULT_PLAN_OVERLAYJSON (the most common one being a leftover"monthly": truefrom the pre-reshape config) fail startup with a clear Pydantic error pointing at the offending field instead of silently dropping._merge_quotaround-trips throughmodel_dump()+ dict update +model_validate(), which preserves enum types (Pydantic v2 coerces JSON strings/ints back to theirPeriod/Retention/Scopeenum members) and benefits from the strict config on each merge.Meters service value-based identity.
api/ee/src/core/meters/service.pywas switched from name-based identity (meter.key.name in Gauge.__members__.keys()) to value-based (meter.key.value in _GAUGE_SLUGS) where the slug sets are computed once at module scope. TheMetersenum (meter table column) and theCounter/Gaugeenums (entitlements) share lowercase string values but are independent types — using values rather than names keeps the dispatch correct if either side is ever renamed without touching the other. The same module-level frozensets_GAUGE_SLUGSand_COUNTER_SLUGSare reused by both the gauge-update and counter-event branches of the Stripe report loop.Catalog → Stripe price wiring. A small helper script
docs/designs/dynamic-access-and-billing/migrate_stripe_pricing.pyannotates an existing flat pricing dict with thefreeandtrialmarkers (annotate(pricing, free_slug=..., trial=(slug, days))) so operators who already have the originalSTRIPE_PRICINGshape can derive the newAGENTA_BILLING_PRICINGform with a single pass. Slot names ("traces","users") are operator-owned and pass through unchanged. The legacy env vars are no longer read by the runtime — only the newAGENTA_BILLING_PRICINGis consulted.Auth-context wiring.
AGENTA_ACCESS_*overrides do not touch the auth path directly, but the access-control resolution functionsget_role(scope, slug)andget_role_permissions(scope, slug)are whatapi/ee/src/utils/permissions.pyconsults at every permission-check call site.WorkspaceRoleis preserved in the codebase as a code-default seed used only by_default_roles(); runtime resolution goes through the effective catalog. Organization-scopeviewerhas no permissions by design (orgs are membership markers); workspace and projectviewerget the code-default read-only permission set so existing project members keep resolving to their historical permission sets. Env overrides may add roles or customize non-minima permissions but cannot remove or rebind theowner/viewerminima — they are re-synthesized after every override.Tests
Migrations
One Alembic revision on this branch:
a1b2c3d4e5f7_unify_org_member_role_to_viewer.py. It unifies theorganization_members.roleserver-side default and any existing"member"rows to"viewer", completing the org-role rename inOrganizationRoleso every scope (organization / workspace / project) shares the same least-permission role slug. Downgrade restores the legacy"member"default and re-maps rows back. Chained after the meters reshape (9d3e8f0a1b2c) so the EEcoreAlembic tree stays single-headed after thefeat/clean-up-metersmerge.Two existing migrations were edited:
7990f1e12f47_create_free_plans.py: comment annotated with the operator constraint oncloud_v0_hobby. The startup-time guard insubscriptions/settings.pymakes a violation fail fast at boot rather than silently corrupt subscription rows.a9f3e8b7c5d1_clean_up_organizations.py: same comment annotation.The companion meters reshape (
9d3e8f0a1b2c_reshape_meters_table.py) shipped on the separatefeat/clean-up-metersbranch and is documented under docs/designs/extend-meters/; it is merged into this branch but is not part of this PR's design scope.Env vars
Added (all optional — code defaults apply when unset):
AGENTA_ACCESS_PLANS— JSON object keyed by plan slug; values are_PlanOverride-shaped (description, flags, counters, gauges, throttles).AGENTA_ACCESS_ROLES— JSON object keyed by scope (organization/workspace/project); values are non-empty arrays of role entries.AGENTA_ACCESS_ROLES_OVERLAY— JSON object with a singleprojectkey; values are role-slug →{permissions?, description?}patches applied to both workspace and project scopes.AGENTA_ACCESS_DEFAULT_PLAN— plan slug for signup; legacyAGENTA_DEFAULT_PLANhonored as fallback.AGENTA_ACCESS_DEFAULT_PLAN_OVERLAY— JSON object mirroring a plan entry's shape; field-merged into the resolved default plan at startup.AGENTA_BILLING_CATALOG— JSON array of catalog entries served by/billing/plans.AGENTA_BILLING_PRICING— JSON object keyed by plan slug; flat shape with operator-owned Stripe slot names (traces,users, etc.) plus reservedfree: boolandtrial: intmarkers.Removed: legacy
STRIPE_PRICING/AGENTA_PRICINGare no longer read;AGENTA_BILLING_TRIAL_PLAN/AGENTA_BILLING_TRIAL_DAYSare collapsed into the per-entry{trial: N}marker onAGENTA_BILLING_PRICING.migrate_stripe_pricing.pyin this design folder annotates an existing flat pricing dict with thefree/trialmarkers.Tests and docs
Unit tests live in
api/ee/tests/pytest/unit/:test_access_controls.py(parser-level tests for plans, roles, and overlay merge — including reserved-slug rejection, duplicate-slug rejection, unknown-permission rejection, throttle-category mismatch rejection — 61 tests),test_controls_env_override.py(subprocess-driven end-to-end env override tests covering every JSON env var across the no-override / override / overlay states),test_billing_settings.py(catalog + pricing parsers under the flat shape,_resolve_trialper-entry trial detection, free-plan validation, default-plan validation),test_billing_router.py(billing handler behavior under env overrides),test_events_retention.py(events flush service: plan iteration, project pagination, per-plan failure isolation),test_admin_retention_routers.py(spans + events admin endpoints — lock namespaces, busy-lock skip, handler shape). EE unit suite green across the three primary access/billing test files: 61 + 83 = 144 tests.docs/designs/dynamic-access-and-billing/carries the design story:research.mdandgap.mdas historical baselines,proposal.mdfor the as-shipped state,tasks.mdas the execution checklist (initial + post-initial sections covering the default-plan relocation, events counter + retention split, and roles overlay),migrate_stripe_pricing.pyas thefree/trialannotation helper, and thissummary.md. Operator-facing docs cover access controls only:docs/docs/self-host/04-dynamic-access-controls.mdxdocuments everyAGENTA_ACCESS_*env var with field tables, examples, and validation rules;docs/docs/self-host/02-configuration.mdxcarries the configuration-table row pointing at those variables. Billing / Stripe wiring is internal-only and not documented in the operator-facing MDX. Thehosting/docker-compose/ee/env.ee.dev.exampleandenv.ee.gh.examplefiles were extended with the newAGENTA_ACCESS_*variables; no billing / Stripe env vars appear in either file.