Bundul
Internal
Browse docs
Waiting for review

decisions

Bundle Run Redesign — Execution Plan (Tracker)

A frozen record of a decision at the time. Superseded by a new record rather than edited.

Written by the build · 2 September 2026

Frozen. A record of a decision at a point in time, not living documentation. Do not update it — supersede it with a new record instead. For how this works today, see docs/explanation/ and docs/generated/.

Bundle Run Redesign — Execution Plan (Tracker)

Living tracker for the redesign. Design spec: bundle-run-redesign.md. Update this doc as work lands — flip checkboxes, set status, add PR links + notes.

Status legend: ⬜ Not started · 🟡 In progress · ✅ Done · ⏸️ Blocked · ❌ Dropped

Phase Title Status PR(s) Notes
0 Admin guard branch feat/bundle-run-redesign-phase1 AdminBasicAuthGuard (REST) + AdminGqlAuthGuard (GraphQL, added in Phase 3); both on ADMIN_USERNAME/PASSWORD
1 Run model + engine seam 🟡 branch feat/bundle-run-redesign-phase1 code complete, build green, unit tests pass; pending commit + staging verify
2 Parallel swaps + payment triggers 🟡 branch feat/bundle-run-redesign-phase1 2a + 2b code-complete behind BUNDLE_RUN_DRIVES_EXECUTION (default OFF); build green, tests pass; needs staging verify with flag ON
3 Read-only admin dashboard 🟡 branch feat/bundle-run-redesign-phase1 backend read API done (admin GraphQL queries + GraphQL admin guard); build green, tests pass; frontend UI is separate repo
4 Admin actions 🟡 branch feat/bundle-run-redesign-phase1 backend mutations + frontend action UI done; build green, 14 backend tests pass; UI verified (login/list live + run-detail/modal mock-render); full live action verify pending real run
L Deferred cleanup after transition

Phase 0 — Admin guard

Goal: admin GraphQL resolvers protected with existing webhookUsername/webhookPassword creds. Acceptance: non-admin call rejected; seeded admin passes; adminRefreshUtilityInvoices no longer unguarded.

  • Create AdminAuthGuard reusing BasicAuthGuard cred validation
  • Apply guard to admin resolver surface (placeholder resolver ok for now)
  • Retrofit guard onto adminRefreshUtilityInvoices mutation
  • Smoke test: authorized vs unauthorized

Phase 1 — Run model + engine seam

Goal: new Mongo run model written alongside the existing Airtable flow; engine abstraction in place. No existing Airtable writes touched. Acceptance: every processPayment produces a BundleRun + items mirroring the live run; AI login only fires for automated services; nothing in the current flow changes behaviour.

Schemas

  • BundleRun schema (fields per design §3)
  • BundleRunItem schema (fields per design §3, incl. history[])
  • CredentialRevealAudit schema
  • BundleRunModule + service (create run, add items, transition item state)

Engine seam

  • ServiceAutomationProvider interface (login, swapCard)
  • Provider registry keyed by service → engine
  • browser-use provider wrapping AiLoginService + CardSwapService
  • puppeteer provider placeholder (throws "not implemented")
  • manual provider (routes to awaiting_manual)

Routing fix

  • Gate AI_LOGIN_REQUESTED emission on requiresManualAutomation === false (user-subscription.service.ts)
  • Stamp fulfillmentMode per item from requiresManualAutomation
  • automated + no session → item swap_failed "reconnect required" (replace silent no_automation skip)

Wire-in

  • processPayment background creates BundleRun + items alongside current Airtable writes
  • Verify against a live/staging run that Mongo run == Airtable view

Phase 2 — Parallel resumable swaps + payment triggers

Goal: run drives execution (not the in-memory promise Map); shared payment-creation path; toggle live; fee fires exactly once. Acceptance: all-automated run charges successful subset with no Airtable round-trip; process restart mid-run resumes; no path can create two Initials for one user.

Execution (2b — DONE, behind BUNDLE_RUN_DRIVES_EXECUTION, default OFF)

  • Per-item bounded-concurrency execution updating BundleRunItemBundleRunExecutorService + mapWithConcurrency (no p-limit; ESM)
  • Run "all done" computed by querying item state (new path is restart-safe; legacy Map path only used when flag OFF)
  • Reconcile cron for runs stuck in swappingBundleRunReconcileJob (every 10 min)
  • awaiting_customer state + subscriptionsVersion bump + push on mid-swap re-auth
  • Trigger: processPayment background emits BUNDLE_RUN_EXECUTION_REQUESTED (flag ON) instead of legacy runAutomation; executor listens in JobsModule (no module cycle)

Payment (2a — DONE)

  • Extract createPaymentForItems(userId, subIds)BundleRunPaymentService (wraps createRecurringSubscription)
  • Initial/Addition derived from live hasExistingPassportSubscriptions (no run-local flag)
  • Per-user payment serialization lock around createPaymentForItemsKeyedMutex (in-process; DB lock needed if multi-instance)
  • autoChargeOnFulfillment toggle on BundleRun.chargePolicy — consumed by the executor
  • Auto-charge on item fulfillment when toggle ON; hold for admin (awaiting_admin) when OFF
  • [~] Webhook re-point — superseded by the flag design: when BUNDLE_RUN_DRIVES_EXECUTION is ON the executor charges via createPaymentForItems directly and the Burger/BurgerTrigger path is bypassed entirely (no re-point needed). When OFF, the legacy webhook is untouched.

Verify

  • Scenario tests: all-automated pass / partial; mixed ON & OFF; all-manual; re-auth mid-swap — bundle-run-executor.service.spec
  • Double-Initial race test (concurrent charges → single fee) — bundle-run-payment.service.spec + keyed-mutex.spec
  • Staging verification with flag ON (real swap + charge end-to-end) — not yet run

Phase 3 — Read-only admin dashboard

Goal: admins see per-service run status over Mongo. Acceptance: a live run is fully visible (automated + manual side by side) with statuses, live URLs, screenshots, errors.

  • Admin GraphQL: list runs (adminBundleRuns, filters: status, user, limit) — guarded by AdminGqlAuthGuard
  • Admin GraphQL: run detail with per-item state (adminBundleRun)
  • Live URL / screenshot / error surfacing per item (in BundleRunItemType)
  • Thin admin frontend (read-only) — separate frontend repo, not this backend
  • Backend read API done: BundleRunType/BundleRunItemType DTOs, BundleRunService.listRuns, AdminGqlAuthGuard (GraphQL-context Basic auth on admin creds)

Phase 4 — Admin actions

Goal: admin fully resolves a run from the dashboard. Acceptance: a mixed run can be taken from awaiting_admin to fully paid in the dashboard; all creds reveals audited.

Backend mutations (DONE — BundleRunAdminModule, guarded by AdminGqlAuthGuard)

  • markBundleRunManualItem(itemId, done|failed) — charges on done when toggle ON
  • revealServiceCredentials(itemId) → decrypted creds + VC, writes CredentialRevealAudit (admin username from Basic header)
  • setBundleRunItemCredsStatus(itemId, valid|invalid) — invalid flags item for re-collect (credsStatus field added)
  • retryBundleRunItems(runId, itemIds) — state-aware (automated→re-queue+execute; awaiting_customer→re-login first; manual→back to queue), Addition-routed via shared charge
  • triggerPaymentForRun(runId) — charges all fulfilled-but-unpaid items in one lock-guarded call
  • setBundleRunAutoCharge(runId, enabled) — per-run toggle override

Frontend (DONE — bundul-admin)

  • Action buttons on run detail: trigger-payment + auto-charge toggle (run-level); per-item Reveal / Done / Fail / Retry / Creds✗ (contextual by status)
  • RevealCredentialsModal (audited reveal, copy buttons, only-present fields)
  • api/admin.ts — all 6 mutations via the shared authed fetch client; react-query invalidation on action
  • Visual verification — login + runs-list verified live (backend up); run-detail + reveal modal verified via client-side mock render (scripts/preview-run-detail.mjs) since prod DB has no runs yet
  • NOTE: local backend .env MONGO_URL points at PROD (/prod) — did NOT seed test data; full live action verification pending a real run (flag-ON bundul in staging)

Phase L — Deferred cleanup (after transition)

Goal: retire the legacy surface once the dashboard is trusted.

  • Stop writing plaintext creds to Airtable (automation-orchestration.service Burger write)
  • Retire Burger / BurgerTrigger writes + Airtable webhook path
  • Remove dead automationServiceUrl / legacy Puppeteer sendLoginPayload path
  • Pick + implement the automation engine (replace puppeteer placeholder if chosen)

Open items to resolve (from design §6)

  • Verify whether split-payment manual path (createSubWithoutDoubleCharging) actually charges — currently appears stubbed (out of scope, but confirm)
  • Deploy caveat (Phase 1): requiresManualAutomation defaults to true. AI login now only fires when it is explicitly false. Before deploying, set requiresManualAutomation=false on every BundulSupportedSubscription that should auto-login, or those services silently become manual (creds-only).
  • adminRefreshUtilityInvoices (old notes) no longer exists in the codebase — nothing to retrofit.

Change log

  • 2026-06-03 — Manual-flow refinement + global default: (1) global autoChargeOnFulfillment default flipped to OFF (BundleRunSetting default + getAutoChargeDefault fallback + run-mapper fallback). (2) Routing decision 3: a requiresManualAutomation=false service with no registry playbook now routes to manual (resolveRouting checks resolveServiceConfig). (3) markManualItem consolidated to success | failed | requires_otp | creds_wrong (success→done+charge-if-ON; requires_otp→awaiting_customer + customer push/version-bump; creds_wrong→failed+credsStatus invalid); dropped the standalone setBundleRunItemCredsStatus mutation. (4) Customer OTP round-trip: submitManualSwapOtp (JWT-guarded BundleRunCustomerResolver) + customerProvidedOtp on the item, surfaced on the admin run-detail as an OTP pill. (5) Admin run-detail manual controls → 4 outcome buttons + OTP pill; /settings toggle already wired. Also fixed two more latent array/Record @Field bugs the schema smoke-test caught (PushNotificationDetails.userIds + .data). Builds green (both repos); bundle-run suites 27/27; admin UI mock-reviewed. No commit.
  • 2026-06-02 — Hardening pass: (1) GraphQL schema boot smoke-test (bundle-run-schema.spec, via GraphQLSchemaFactory, no Mongo boot) — closes the "schema only verified at deploy" gap; it caught + forced a fix to a real latent bug (RecurrTransactionsWithoutCapitalOneResponse.transactions was a bare @Field() on an array → @Field(() => [UserRecurringTransactions])). (2) Audit-log read surface: adminCredentialReveals query + CredentialRevealAuditType + BundleRunService.listCredentialReveals + /audit page in bundul-admin (reveals were write-only). (3) Noted that the working .env MONGO_URL points at PROD — for local dev use the existing docker-compose.yml (runs API + local Mongo against bundul-local) or set a local MONGO_URL. (A redundant docker-compose.local.yml added here was later removed.) Builds green (both repos); bundle-run backend suite 10/10; audit UI mock-rendered. No commit.
  • 2026-06-01 — Plan created; design locked. All phases ⬜.
  • 2026-06-01 — Phase 0 ✅ (existing AdminBasicAuthGuard). Phase 1 🟡: run-model schemas, BundleRunModule/BundleRunService, ServiceAutomationProvider seam + registry + 3 providers, AI-login gating on requiresManualAutomation, shadow-write of BundleRun into processPayment background. Build green; 23 unit tests pass. Branch feat/bundle-run-redesign-phase1.
  • 2026-06-01 — Phase 2a 🟡: KeyedMutex (per-user lock, CommonModule) + BundleRunPaymentService.createPaymentForItems (lock-guarded; Initial/Addition from live hasExistingPassportSubscriptions; stamps run items). No live-path change — not yet called (consumed in 2b). Build green; +12 unit tests (35 total) incl. the double-Initial race test.
  • 2026-06-02 — Phase 4 🟡 (backend mutations): new BundleRunAdminModule (own module to avoid PaymentModule cycle) with BundleRunAdminService + guarded BundleRunAdminMutationResolver: triggerPaymentForRun, markBundleRunManualItem, revealServiceCredentials (audited via CredentialRevealAudit), setBundleRunItemCredsStatus (added credsStatus field), retryBundleRunItems (state-aware, re-emits execution event), setBundleRunAutoCharge. Charges go through the lock-guarded BundleRunPaymentService; retries hand back to the executor. Build green; +14 tests (66 total). Frontend buttons + live review next. No commit.
  • 2026-06-02 — Phase 3 🟡 (backend read API): AdminGqlAuthGuard (GraphQL-context Basic auth on admin creds, CommonModule), BundleRunType/BundleRunItemType read DTOs, BundleRunService.listRuns, BundleRunAdminResolver (adminBundleRuns, adminBundleRun) wired into BundleRunModule. Build green; +9 tests (52 total). Frontend UI is a separate repo. Residual: verify GraphQL schema generates at app boot (unit tests don't boot the GraphQL module). No commit.
  • 2026-06-01 — Phase 2b 🟡: BundleRunExecutorService (JobsModule) drives swaps via the provider registry with bounded concurrency, auto-charges per autoChargeOnFulfillment, handles awaiting_customer (subscriptionsVersion bump + push); BundleRunReconcileJob (10-min cron); mapWithConcurrency helper; BUNDLE_RUN_EXECUTION_REQUESTED event; processPayment branches on BUNDLE_RUN_DRIVES_EXECUTION (default OFF) — legacy path untouched when OFF. Build green; +8 executor tests (43 total). NOT deployed; needs staging verify with flag ON. NOTE: commits were made earlier in error (before the no-commit rule was re-stated) — no commits since.

This is a record of a decision at the time. It is not edited — write a new record that supersedes it.