Skip to content

[feat] Seed starter credits at signup via budget-capped proxy keys (EE) - #6138

Merged
mmabrouk merged 1 commit into
release/v0.114.0from
credits-starter-seeding
Aug 23, 2026
Merged

[feat] Seed starter credits at signup via budget-capped proxy keys (EE)#6138
mmabrouk merged 1 commit into
release/v0.114.0from
credits-starter-seeding

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Aug 20, 2026

Copy link
Copy Markdown
Member

Context

New eligible EE projects receive a funded provider connection. That credential is issued and maintained by Agenta, so users must be able to run with it without reading, replacing, renaming, probing, or deleting it.

Changes

The starter-credits bridge now creates the row through the typed managed-secret boundary:

management = SecretManagementDTO(
    manager=SecretManager.STARTER_CREDITS_BRIDGE,
    policy=SecretManagementPolicy.MANAGER_ONLY,
)
write_only = True

The bridge chooses lifecycle ownership and value visibility independently. It keeps a separate proxy-origin identifier for audit metadata and uses user-facing copy for the Vault header.

Vault invalidation now lives at the mutation boundary, so this internal create invalidates the same cached list as public create, update, and delete. The bridge refuses to mint or seed when AGENTA_SERVICES_INTERNAL_KEY is absent, blank, or a known placeholder.

No repair, owner-update, release, delete, or universal bypass path is introduced. This release creates one bounded row once.

Tests / notes

  • Seeded-credits seeding and client suites passed 77 tests during this slice.
  • The final API verification, including startup failure behavior, passed 229 tests after removing four unsupported write-only webhook compatibility cases.
  • Ruff formatting and checks passed.
  • No database migration or feature flag is included.

What to QA

  • Create a new eligible project. One starter-credit connection must be seeded.
  • Inspect storage. It must contain the typed manager, manager_only, and write_only=True.
  • List through the public API. It must expose only the public policy and no plaintext credential.
  • Prime the Vault list cache before seeding. The new connection must appear immediately after creation.
  • Exhaust the funded key. Existing budget and cleanup behavior must remain intact.

Depends on #6165 through the managed-secrets base.

@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Aug 23, 2026 2:25pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added an optional starter-credit program that provisions budget-limited credits for eligible organizations during signup.
    • Added safeguards for policy limits, feature flags, team budgets, expiration, and partial or exhausted credit states.
    • Added an administrator endpoint to reconcile starter-credit accounts.
    • Added configuration options for enabling and customizing starter-credit behavior.
  • Bug Fixes

    • Added automatic recovery for incomplete credit provisioning without interrupting signup.
  • Tests

    • Added coverage for credit provisioning, reconciliation, limits, failures, and administrator operations.

Walkthrough

Changes

The pull request adds a configurable starter-credit bridge. It provides an authenticated proxy client, guarded seeding during signup, reconciliation for partial states, and an admin endpoint for manual reconciliation.

Starter credits bridge

Layer / File(s) Summary
Configuration and proxy contracts
api/oss/src/utils/env.py, api/ee/src/core/starter_credits_bridge/types.py, api/ee/src/core/starter_credits_bridge/client.py, api/ee/tests/pytest/unit/test_starter_credits_bridge_client.py
Adds environment parsing, bridge models and exceptions, authenticated proxy operations, response validation, and client tests.
Guarded seeding and policy enforcement
api/ee/src/core/starter_credits_bridge/service.py, api/ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py
Adds bounded seeding with configuration, feature-flag, team-budget, mint-policy, and velocity controls. Tests cover gates, fallbacks, limits, and failure isolation.
Key and vault convergence
api/ee/src/core/starter_credits_bridge/service.py, api/ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py
Adds key minting, orphan-budget recovery, vault writes, ownership checks, exhausted-key blocking, and reconciliation of partial states.
Signup and admin integration
api/ee/src/core/organizations/service.py, api/ee/src/apis/fastapi/starter_credits_bridge/router.py, api/ee/src/main.py, api/ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py
Runs safe seeding after signup provisioning and registers POST /admin/starter-credits/reconcile for administrative reconciliation.

Estimated code review effort: 5 (Critical) | ~90 minutes

Merge Risk: 🟠 High · up to 146c0

This PR adds automatic starter provider credentials during signup, but unresolved issues could expose virtual keys in logs, modify credentials the system does not own, misapply rollout decisions, and materially delay or disrupt signup. Merge should be blocked until the security, ownership, and signup-path risks are addressed.

Sequence Diagram(s)

sequenceDiagram
  participant SignupService
  participant StarterCreditsBridgeService
  participant StarterCreditsProxyClient
  participant VaultService
  SignupService->>StarterCreditsBridgeService: seed_starter_credits_bridge_safely(organization_id, organization_email)
  StarterCreditsBridgeService->>StarterCreditsProxyClient: verify team and mint or recover key
  StarterCreditsBridgeService->>VaultService: create or repair custom-provider secret
  StarterCreditsBridgeService->>StarterCreditsProxyClient: update metadata or block exhausted key
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.88% which is insufficient. The required threshold is 60.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 169 functions across 9 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The description identifies the stacked pull requests and specifies their required merge order.
Out of Scope Changes check ✅ Passed The summarized changes support the stated starter-credit bridge objectives and do not indicate unrelated feature work.
Description check ✅ Passed The description directly explains starter-credit seeding, managed-secret storage, safeguards, tests, and QA steps covered by the changeset.
Title check ✅ Passed The title clearly summarizes the main change: seeding starter credits at signup through budget-capped proxy keys in the EE codebase.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch credits-starter-seeding

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai 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.

Actionable comments posted: 8

🧹 Nitpick comments (9)
api/oss/src/utils/env.py (1)

1615-1621: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Warn when enabled is true but the bridge stays disarmed.

If an operator sets AGENTA_STARTER_CREDITS_BRIDGE_ENABLED=true and omits PROXY_PUBLIC_URL, MASTER_KEY, or TEAM_ID, armed returns False. seed_starter_credits_bridge then returns at line 93 in api/ee/src/core/starter_credits_bridge/service.py without a log line. The feature is silently inert and the misconfiguration is invisible. Other configs in this file emit a startup warnings.warn for surprising states (see ServicesCodeConfig._warn_sandbox_runner_mode, line 380).

♻️ Proposed startup warning
     model_config = ConfigDict(extra="ignore")
 
+    `@model_validator`(mode="after")
+    def _warn_enabled_but_disarmed(self) -> "StarterCreditsBridgeConfig":
+        if self.enabled and not self.armed:
+            warnings.warn(
+                "AGENTA_STARTER_CREDITS_BRIDGE_ENABLED is true but PROXY_PUBLIC_URL, "
+                "MASTER_KEY, or TEAM_ID is missing; starter-credit seeding stays off.",
+                stacklevel=2,
+            )
+        return self
+
     `@property`
     def armed(self) -> bool:
api/ee/tests/pytest/unit/test_starter_credits_bridge_client.py (1)

216-238: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a case for the non-JSON response branch.

StarterCreditsProxyClient._request maps a 2xx response with a non-JSON body to ProxyRequestError (client.py, lines 140-146). No test covers that branch. A proxy that returns an HTML error page behind a gateway hits it.

💚 Proposed test
    async def test_non_json_success_body_raises(self):
        def handler(request: httpx.Request) -> httpx.Response:
            return httpx.Response(200, text="<html>gateway</html>")

        client = _client_with_handler(handler)
        with pytest.raises(ProxyRequestError) as excinfo:
            await client.get_team_info(team_id="team-1")
        assert excinfo.value.status_code == 200
api/ee/src/core/starter_credits_bridge/service.py (2)

51-54: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Give the verified-team cache a TTL.

_verified_team_ids caches positive verification for the process lifetime and is never invalidated. If an operator lowers the program team's max_budget or adds a budget_duration after the first successful check, every running API process keeps minting until it restarts. _team_ceiling_verified describes the team ceiling as the always-on bound on total exposure, so an unbounded positive cache weakens that guarantee.

Store the verification in the shared cache with a short TTL, or record a timestamp per team id and re-check after a few minutes.


102-120: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Check the existing vault row before the proxy round trip.

_team_ceiling_verified performs a network call to the proxy at line 103. The already-seeded check happens later at line 115. On the first call for a process, an already-seeded organization therefore pays a full proxy round trip inside the signup timeout before returning at line 120. Move the default-project lookup and the get_secret_by_slug check above the team-ceiling verification. Both are local and cheap, and neither depends on the proxy.

api/ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py (2)

896-903: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Exercise the route through the HTTP layer.

The test calls instance.reconcile directly. That skips FastAPI request validation, the mounted prefix, and any authorization dependency. The test therefore passes whether or not the endpoint is protected. Use fastapi.testclient.TestClient against an app that includes instance.admin_router, and add a case that asserts an unauthenticated request is rejected.


55-57: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Align FakeVaultService with VaultService signatures.

create_secret omits organization_id and makes project_id required. delete_secret makes secret_id keyword-only. Match the production signatures so the fake detects contract changes.

api/ee/src/apis/fastapi/starter_credits_bridge/router.py (1)

19-20: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Type organization_id as UUID.

organization_id is declared as str. reconcile_starter_credits_bridge forwards it to db_manager.get_default_project_by_organization_id and to client.list_keys as the key alias. A malformed value reaches the database layer before it is rejected. A UUID field makes FastAPI return 422 for bad input.

♻️ Proposed change
+from uuid import UUID
+
 class StarterCreditsReconcileRequest(BaseModel):
-    organization_id: str
+    organization_id: UUID

The service takes organization_id: str, so pass str(request_body.organization_id) at the call site.

api/ee/src/core/starter_credits_bridge/types.py (1)

44-46: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Protect MintedKey.key with SecretStr.

Change the field to SecretStr. Pass minted.key.get_secret_value() at the two current use sites in service.py.

api/ee/src/core/starter_credits_bridge/client.py (1)

66-75: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Pin the LiteLLM proxy contract.

Record the deployed LiteLLM version and validate the key, keys, and team_info response shapes. The client has no version or schema pin, so proxy upgrades can change these cross-service contracts. Missing key fails explicitly, but the seeding service then degrades to no starter credits.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: d08dff55-d1aa-499a-bc5e-aa9918fcb8e1

📥 Commits

Reviewing files that changed from the base of the PR and between bc2481b and 146c014.

📒 Files selected for processing (11)
  • api/ee/src/apis/fastapi/starter_credits_bridge/__init__.py
  • api/ee/src/apis/fastapi/starter_credits_bridge/router.py
  • api/ee/src/core/organizations/service.py
  • api/ee/src/core/starter_credits_bridge/__init__.py
  • api/ee/src/core/starter_credits_bridge/client.py
  • api/ee/src/core/starter_credits_bridge/service.py
  • api/ee/src/core/starter_credits_bridge/types.py
  • api/ee/src/main.py
  • api/ee/tests/pytest/unit/test_starter_credits_bridge_client.py
  • api/ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py
  • api/oss/src/utils/env.py

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment thread api/ee/src/core/starter_credits_bridge/client.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py Outdated
Comment thread api/ee/src/core/starter_credits_bridge/service.py Outdated
Comment thread api/ee/src/core/starter_credits_bridge/service.py Outdated
Comment thread api/ee/src/core/starter_credits_bridge/service.py Outdated
Comment thread api/ee/src/core/starter_credits_bridge/service.py
Comment thread api/oss/src/utils/env.py Outdated
@mmabrouk

Copy link
Copy Markdown
Member Author

Review requested by @mmabrouk. Generated by OpenAI Codex CLI (gpt-5.6-sol, xhigh reasoning) with full cross-PR context; posted by the orchestrating agent. Findings are being addressed on this branch.

Codex review (gpt-5.6-sol, xhigh)

Do not merge yet. The implementation has a direct full-refill path after partial spend and a concurrency race that can permanently pair a dead vault credential with a live proxy orphan. The signup hook generally contains asynchronous failures, but policy, ownership, and team checks do not meet the stated fail-closed and exact-identity contracts.

Findings

  1. [P0] Reconcile can increase an organization’s lifetime grant — The row-without-key path deletes the vault row and calls _mint_and_seed, which starts by minting the full config.grant_usd; any historical spend on the missing key is unknowable and therefore replaced in full. The orphan path also reads the remaining balance, deletes the only durable record of it, and then mints the replacement. If deletion succeeds but that mint fails or times out, the next reconcile sees no alias and again mints the full grant. For example, a key with a $10 maximum and $4 spent can become a fresh $10 key after the delete/remint failure boundary, producing $14 of lifetime grant (api/ee/src/core/starter_credits_bridge/service.py:196, api/ee/src/core/starter_credits_bridge/service.py:198, api/ee/src/core/starter_credits_bridge/service.py:230, api/ee/src/core/starter_credits_bridge/service.py:239, api/ee/src/core/starter_credits_bridge/service.py:261).

  2. [P1] Concurrent seeders can strand a dead vault credential and a live orphan — Two calls can both observe no vault row. Seeder A mints K1; seeder B gets the alias conflict, deletes K1, and mints K2. If A wins the vault uniqueness race, the row stores dead K1 while B’s insert fails and leaves K2 live. Reconcile then reports "healthy" because it only requires a row plus any key carrying the origin marker, without checking that the key’s secret_id matches the row or that the row contains that key (api/ee/src/core/starter_credits_bridge/service.py:114, api/ee/src/core/starter_credits_bridge/service.py:166, api/ee/src/core/starter_credits_bridge/service.py:173, api/ee/src/core/starter_credits_bridge/service.py:230, api/ee/src/core/starter_credits_bridge/service.py:264).

  3. [P1] Manual reconcile can create a first grant without policy approval — Reconcile accepts only an organization ID, rechecks the feature flag and team, and mints whenever both key and row are absent. It never evaluates the email rules or consumes the global/domain counters, and there is no durable marker proving this organization previously passed them. An organization refused for a digit-bearing work email or because a cap was reached is indistinguishable from an approved organization that failed before minting. The test currently asserts this unsafe contract by expecting an entirely empty state to become "seeded" (api/ee/src/core/starter_credits_bridge/service.py:134, api/ee/src/core/starter_credits_bridge/service.py:150, api/ee/src/core/starter_credits_bridge/service.py:176, api/ee/src/apis/fastapi/starter_credits_bridge/router.py:19, api/ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py:472).

  4. [P1] PostHog outage fallback crosses organization boundaries — Live evaluation correctly uses the organization ID as the PostHog distinct ID, but the cached result is keyed only by flag name. If organization A evaluates true and PostHog then fails, organization B receives A’s cached true even if B would be outside a targeted or percentage rollout. This makes an outage bypass the gate instead of preserving the last decision for that organization (api/ee/src/core/starter_credits_bridge/service.py:433, api/ee/src/core/starter_credits_bridge/service.py:443, api/ee/src/core/starter_credits_bridge/service.py:450).

  5. [P1] Ownership is neither exact-ID-bound nor unforgeable — Recording secret_id on the proxy key is best-effort and failure is treated as successful seeding. Reconcile later identifies a key using only metadata.origin, treats any such key plus any slug row as healthy, and authorizes row deletion using only header.description, which is ordinary user-controlled vault data. A user-created starter-credits row with that description can therefore be deleted when the proxy key is absent, while a stale or mismatched row/key pair can be accepted as healthy. The exact secret ID plus origin invariant must be mandatory at every ownership-sensitive transition (api/ee/src/core/starter_credits_bridge/service.py:167, api/ee/src/core/starter_credits_bridge/service.py:173, api/ee/src/core/starter_credits_bridge/service.py:188, api/ee/src/core/starter_credits_bridge/service.py:271, api/ee/src/core/starter_credits_bridge/service.py:337).

  6. [P1] Real money and throughput limits remain hardcoded, and malformed configuration can fail open — The source contains the real grant, parallelism, RPM, and TPM defaults rather than resolving those values from the policy payload or explicit overrides. An invalid grant environment value silently falls back to the source default, while any unrecognized BLOCK_DIGIT_LOCALS value becomes false and overrides the PostHog rule. Separately, a successful but malformed live policy payload is treated like an outage and replaced with the cached old policy, potentially preserving looser caps after a bad rollout (api/oss/src/utils/env.py:1505, api/oss/src/utils/env.py:1515, api/oss/src/utils/env.py:1553, api/oss/src/utils/env.py:1563, api/ee/src/core/starter_credits_bridge/service.py:481, api/ee/src/core/starter_credits_bridge/service.py:489).

  7. [P1] Team-ceiling verification can become stale for the worker’s entire lifetime — After one successful lookup, the team ID enters a process-global set and every later mint skips /team/info. Removing the ceiling or changing it to a resetting budget after that first verification therefore bypasses the mandatory check until every worker restarts. The unit test explicitly enshrines this by expecting only one proxy lookup across repeated checks (api/ee/src/core/starter_credits_bridge/service.py:53, api/ee/src/core/starter_credits_bridge/service.py:375, api/ee/src/core/starter_credits_bridge/service.py:420, api/ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py:567).

  8. [P2] The claimed ten-second signup bound is not an actual wall-time bound — Both PostHog SDK calls execute synchronously on the event-loop thread, so asyncio.timeout cannot preempt them while they block. After any caught timeout or failure, the wrapper also awaits an alert with a separate five-second timeout outside the ten-second context. Ordinary asynchronous failures remain swallowed, but the hook can exceed its documented upper bound by at least the alert duration and potentially by a blocking PostHog call (api/ee/src/core/starter_credits_bridge/service.py:65, api/ee/src/core/starter_credits_bridge/service.py:77, api/ee/src/core/starter_credits_bridge/service.py:435, api/ee/src/core/starter_credits_bridge/service.py:474, api/ee/src/core/starter_credits_bridge/service.py:608).

  9. [P2] Any 400 mentioning “alias” triggers destructive compensation — The client classifies every HTTP 400 whose body contains alias as an existing-key conflict, although the measured contract is specifically an “already exists” error. A schema or validation response mentioning key_alias can therefore enter the orphan path, inspect an unrelated existing record, delete it by alias, and retry the same invalid mint. Match the measured conflict signal narrowly before permitting deletion (api/ee/src/core/starter_credits_bridge/client.py:127, api/ee/src/core/starter_credits_bridge/client.py:130, api/ee/src/core/starter_credits_bridge/service.py:232, api/ee/src/core/starter_credits_bridge/service.py:261).

Test gaps

  • Exercise every delete/remint failure boundary: delete succeeded followed by 5xx, timeout, cancellation, malformed response, or process death, then retry and prove lifetime grant never increases.

  • Run two seed/reconcile calls concurrently with realistic alias uniqueness and the real vault unique constraint; assert the surviving row’s secret exactly matches the surviving key.

  • Cover row-without-key after nonzero historical spend and require refusal unless remaining grant is durably known.

  • Test a policy-refused organization against reconcile; the current empty-state test asserts the opposite contract.

  • Cache opposite feature-flag decisions for two organization IDs, simulate PostHog failure, and verify there is no cross-organization authorization.

  • Cover missing, mismatched, and forged secret_id/origin combinations, including a user-created row with the same slug and description and a failed metadata update.

  • Test malformed live policy after a valid cached policy, unknown payload fields, non-finite grants, and invalid boolean overrides.

  • Reconfigure the LiteLLM team after initial verification and verify subsequent mints refuse resetting, missing, or non-finite ceilings.

  • Test the signup hook at its real call site, including synchronous blocking during PostHog evaluation and the alert duration after timeout.

Nits

  • Replace config: Any on the money path with the concrete configuration type (api/ee/src/core/starter_credits_bridge/service.py:211).

  • Model the reconcile organization ID as UUID and give the route an explicit operation_id (api/ee/src/apis/fastapi/starter_credits_bridge/router.py:19).

  • Remove the unused get_key_info client method unless an imminent exact-identity check will use it (api/ee/src/core/starter_credits_bridge/client.py:94).

  • _send_alert should call raise_for_status; currently webhook 4xx/5xx responses are silently treated as successful delivery (api/ee/src/core/starter_credits_bridge/service.py:608).

The bridge-specific module, environment, and origin names correctly avoid the permanent wallet, signup-grant, and builtin namespaces.

@mmabrouk

Copy link
Copy Markdown
Member Author

Codex review findings addressed in commit 9cd9e0e (see the "Review response" section of the PR body for the finding-by-finding map). The headline change: provisioning is now a row-first protocol — the vault row is created before every mint and carries an HMAC-signed record of the authorized remaining, so the never-increase-the-grant invariant survives every failure boundary (including the delete-then-mint crash and the row-without-key state, which is now assumed spent). "Healthy" requires the exact secret_id pairing recorded at mint; reconcile never creates a first grant from an empty state; the flag outage-cache is per organization; grant and per-key limits moved into the policy payload with strict parsing and fail-closed malformed handling; team verification re-runs on a 10-minute TTL; the PostHog calls run in a worker thread inside the real 10s bound; the alias-conflict match is narrowed to the measured wording; error details are redacted of key material. CodeRabbit majors and nits folded in (origin-checked key actions, velocity slot handback, raise_for_status on the alert webhook, UUID-typed reconcile route with operation_id, unused client methods dropped). 65 unit tests cover the review's test-gap list.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewer guide for this PR, written against the current head - the repair/reconcile machinery was removed in the last commit, so seeding is now a single bounded mint-then-write with no repair path and no admin reconcile route. That deletion is what makes this reviewable: the module is a straight line of gates followed by two writes. The inline notes follow that line in order - where it hooks into signup, each gate and how it fails, the mint call, the row write, and what happens when either half fails.

Comment thread api/ee/src/core/organizations/service.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py
Comment thread api/ee/src/core/starter_credits_bridge/service.py Outdated
Comment thread api/ee/src/core/starter_credits_bridge/service.py
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from 0575a64 to 818d5a4 Compare August 21, 2026 21:45
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from 818d5a4 to 5215139 Compare August 21, 2026 21:57
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from 5215139 to 74298e4 Compare August 21, 2026 22:02
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from 74298e4 to a84533a Compare August 21, 2026 22:14
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from a84533a to e5d0e74 Compare August 22, 2026 11:14
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from e5d0e74 to a3fa961 Compare August 22, 2026 22:02
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from a3fa961 to ac58713 Compare August 22, 2026 22:25
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from ac58713 to dfd33cc Compare August 22, 2026 22:29
@mmabrouk
mmabrouk force-pushed the credits-starter-seeding branch from dfd33cc to ab97118 Compare August 22, 2026 22:53

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review round 3 (final heads)

Head dc92975a4d, base managed-secrets at 6c70e7b3c9.

The lane content is byte-identical to round 2 (ab97118eb4). The whole delta since then is the rebase onto the new base. So this round re-checks the properties against the final head and looks at the two remaining gaps.

Tests run read-only: pytest ee/tests/pytest/unit/test_starter_credits_bridge_client.py ee/tests/pytest/unit/test_starter_credits_bridge_seeding.py -q → 77 passed.

Verified addressed

Every CodeRabbit thread on this PR is resolved and the resolutions hold in the code at this head. Spot-checked all of them:

  • Key material out of detail. client.py:107-108 scrubs sk- patterns from the response body before it becomes an exception message, and truncates. Transport failures carry only the exception class name.
  • Ownership before destructive proxy actions. Every key the module touches goes through the origin plus organization check on the key's master-key-only metadata. A key without the marker is refused rather than deleted or blocked.
  • Ownership no longer reads header.description. _row_origin is gone. The description is display only.
  • PostHog calls run in a worker thread, so the 10 second signup bound can actually preempt them.
  • The flag outage cache is keyed per organization.
  • Velocity slots are released when an attempt funds no mint.
  • The grant env parsing rejects unparseable, non-finite, and non-positive values at import time.
  • The Codex review findings are addressed by the row-first protocol. I re-traced the three crash windows below and they hold.

The admin reconcile router that CodeRabbit reviewed is no longer in the diff, which removes that whole HTTP surface and the comments attached to it.

Disclosure check

Clean. I grepped the diff and the whole branch for the program's name, for dollar amounts, and for IP addresses. Nothing. The only hostnames in the diff are fictional test values. No cap number, grant size, or budget lives in source: every policy value arrives in the flag payload. The one exception is the development policy used when the deployment configured no PostHog of its own, which is the right place for a number.

Confirmed properties

  • Managed and write-only, both explicit. Single creation site at service.py:324-342: create_managed_secret with write_only=True and management=SecretManagementDTO(manager=STARTER_CREDITS_BRIDGE, policy=MANAGER_ONLY).
  • Display name. STARTER_CREDITS_NAME = "Agenta" (service.py:58), used for both the header name and provider_slug, which matters because the slug is the namespace half of every model key the connection publishes.
  • Policy source and dev fallback. The condition is exactly if not env.posthog.api_key_configured (service.py:496), and api_key_configured is a fresh field that is true only when this deployment supplied the key (env.py:1363). Using it rather than posthog.enabled is correct, since the built-in fallback key makes enabled true in every checkout.
  • Skipped cleanly when not armed. if not config.armed: return at service.py:130. OSS never imports the module at all; the import sits behind the edition check in commoners.py.
  • Nothing can reach signup. seed_starter_credits_bridge_safely has no await outside its try, wraps everything in asyncio.timeout plus a bare except Exception, and the alert it fires is itself guarded. This matters because the signup path deletes the new user when setup raises.
  • One grant per organization. Two independent guards, either sufficient: the proxy key_alias is the organization id, so a duplicate mint conflicts and is treated as already-seeded without re-minting; and the vault unique index on (project_id, slug) rejects a second row, after which the just-minted key is blocked. Crash after mint and before the row write blocks the key. Crash after the row write and before the mint is unreachable, since the mint strictly precedes the write.
  • Marker stability. "starter-credits-bridge" is consistent between the vault manager enum and the proxy origin marker.

Should

1. A deleted policy payload does not fail closed while the cache is warm. service.py:513-530. When PostHog is reachable and the payload has been removed, raw is None, so payload stays None and live_malformed stays False. The next branch then reads the Redis-cached payload and seeds on it. The comment directly above says the opposite, and env.py documents clearing the payload as the redeploy-free kill switch. The window is bounded at the cache TTL of five minutes and get_cache does not renew it, so this is not unbounded. But an operator who pulls the payload to stop seeding will watch it keep seeding, which is the moment they most need it to stop. A malformed payload does fail closed and does ignore the cache, so the fix is small: treat a live None the same way, or set a flag that skips the cache read. No test covers it, because the fixture resets the cache between tests.

2. The alert webhook URL reaches the logs. service.py:690-696. response.raise_for_status() raises httpx.HTTPStatusError, whose message embeds the full request URL, and the handler logs it with exc_info=True. An incoming-webhook URL is a bearer credential, and a revoked webhook returning 404 is the ordinary case, not an edge one. Log the status code and the exception class rather than the exception.

Nit

3. A DB error on the row insert can render the minted key into the log. service.py:107-111 logs the seeding exception with exc_info=True. The vault insert encrypts through a SQLAlchemy bind expression, so the plaintext payload and the crypt key are ordinary bind parameters, and StatementError appends [SQL: ...] [parameters: ...] to its string. Any DBAPI error on that insert, such as a connection drop or a statement timeout, puts both in the log line. This is a pre-existing class rather than something this PR invents, and the same shape already exists for user-created secrets. It is worth noting here only because this path handles a freshly minted funded key. Setting hide_parameters on the engine would close it everywhere at once.

4. A timeout between the mint and the row write skips the block. asyncio.timeout raises CancelledError, which the except Exception at service.py:222 does not catch, so _block_key is skipped. Financially harmless, since the key never left the backend and nobody can spend it. The cost is that it permanently consumes the organization's key_alias, so no later repair could mint for that organization.

5. _release_velocity_slots gives up on the first Redis error. service.py:663-669 uses return inside the loop rather than continue, so a transient failure on the first counter leaves the remaining slots consumed.

6. Velocity keys are built from two separate clock reads. service.py:569-570 calls now() more than once, and again on release. An allow and release pair that straddles an hour boundary decrements a different key than it incremented.

Cross-lane note, not a defect

The seeded model is stored as the slug vertex_ai/gemini-3.6-flash. There is no model name field to put a human name in: CustomModelSettingsDTO carries slug and extras only. The string "Gemini 3.6 Flash" is produced client-side, by stripping the connection namespace off the model key and looking the bare id up in the curated catalog. That lookup lives in the frontend lane, not here. So a user sees the friendly name only once that lane is deployed, and sees the raw slug before it.

Verdict

Safe to merge in order. Findings 1 and 2 are worth fixing, but neither can overspend and neither can break a signup. Finding 1 delays a kill switch by up to five minutes; the enabled flag remains an immediate switch. Finding 2 is a credential in a log rather than a credential on the wire.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend enhancement New feature or request lgtm This PR has been approved by a maintainer size:XXL This PR changes 1000+ lines, ignoring generated files. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant