diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 00000000..3c0a4fbc Binary files /dev/null and b/dump.rdb differ diff --git a/internal/db/migrations/067_teams_is_test_cohort.sql b/internal/db/migrations/067_teams_is_test_cohort.sql new file mode 100644 index 00000000..57a40fd7 --- /dev/null +++ b/internal/db/migrations/067_teams_is_test_cohort.sql @@ -0,0 +1,45 @@ +-- Migration: 067_teams_is_test_cohort +-- +-- Add teams.is_test_cohort — the cohort-isolation foundation (W0 / PR-1) for the +-- continuous synthetic-monitoring program. See +-- docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.5/§1.6. +-- +-- Background: +-- The synthetic-monitoring program seeds durable per-tier test teams +-- (`synthetic+hobby@instanode.dev`, etc.) and provisions real resources on a +-- continuous cadence. Without an isolation flag those seeded teams would look +-- like real customers to every background job and funnel/billing surface: +-- - quota nudges + expiry/TTL warning emails would fire at synthetic +-- addresses (Brevo-rejected noise + ledger pollution), +-- - the billing reconciler would flag them as drift (no real Razorpay sub), +-- - the conversion funnel / churn predictor would count synthetic activity +-- in the real 2%/20% targets, +-- - self-serve checkout / change-plan would attempt a real charge. +-- +-- `is_test_cohort` is the single tag every such path keys off to no-op or +-- exclude the team. It is INERT by default: every existing team gets `false`, +-- so behaviour is unchanged for all real teams until a seeder sets it true. +-- +-- This migration: +-- - Adds teams.is_test_cohort BOOLEAN NOT NULL DEFAULT false. +-- - Adds a tiny PARTIAL index on the true rows only. The team-iterating jobs +-- (worker-side, follow-up PR) filter `AND NOT is_test_cohort`; the api-side +-- charge guards (this PR) look up a single team by id. The partial index +-- covers the "list the synthetic teams" / "is this team synthetic" lookups +-- while staying near-zero cost (only the handful of seeded rows are +-- indexed — the DEFAULT-false universe is excluded). +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS, safe to +-- re-run on every startup (matches the RunMigrations forward-only contract). +-- +-- Rollback (forward-only project; documented, not auto-applied): +-- DROP INDEX IF EXISTS idx_teams_is_test_cohort; +-- ALTER TABLE teams DROP COLUMN IF EXISTS is_test_cohort; +-- (Safe — no FK or other constraint references this column.) + +ALTER TABLE teams + ADD COLUMN IF NOT EXISTS is_test_cohort BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX IF NOT EXISTS idx_teams_is_test_cohort + ON teams (id) + WHERE is_test_cohort; diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index ef5e1ed5..11361b16 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -51,6 +51,17 @@ const checkoutInflightTTL = 60 * time.Second // the same team also bounces — the subscription belongs to the team. const checkoutInflightKeyPrefix = "team_checkout_inflight:" +// errCodeSyntheticTestCohort is returned by the self-serve charge-initiation +// handlers (CreateCheckoutAPI, ChangePlanAPI) when the authenticated team is a +// synthetic-monitoring test cohort (teams.is_test_cohort, migration 067). A +// synthetic team must NEVER reach the real Razorpay subscription-create / +// change-plan call — that would create a live charge for a test identity. The +// guard returns a deterministic 403 with this distinct code BEFORE the Redis +// dedup slot or any Razorpay call, so the synthetic runner gets a stable, +// non-charging response it can assert on. See +// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. +const errCodeSyntheticTestCohort = "synthetic_test_cohort" + // monthlyOngoingTotalCount / yearlyOngoingTotalCount are the Razorpay // subscription `total_count` values for an ONGOING (effectively indefinite) // plan. Razorpay's create-subscription API requires a finite total_count, so @@ -659,6 +670,44 @@ func (h *BillingHandler) requireVerifiedEmail(c *fiber.Ctx, action string) (bool AgentActionEmailNotVerified, "") } +// rejectIfTestCohort is the api-side synthetic-cohort skip-guard for the +// self-serve charge-initiation handlers. It looks up teams.is_test_cohort +// (migration 067) and, when true, writes a deterministic 403 +// synthetic_test_cohort response and returns ok=false so the caller returns +// immediately WITHOUT reaching Razorpay. A lookup error fails CLOSED (treated +// as "not a test cohort" → proceed) so a transient DB blip never blocks a real +// paying customer's checkout — the worst case is one synthetic call slipping +// through to the inflight dedup, which the seeded test teams have no real +// subscription to complete anyway. +// +// ok=true means "not a test cohort, proceed". On ok=false the returned error is +// the already-written fiber response (or ErrResponseWritten) and the caller must +// return it unchanged. +func (h *BillingHandler) rejectIfTestCohort(c *fiber.Ctx, teamID uuid.UUID, action string) (ok bool, resp error) { + // No DB wired: only happens on db-independent handler paths exercised in + // tests (e.g. the Redis SETNX dedup guard, which short-circuits before any + // DB use). In prod h.db is always set. Nothing to check — proceed. + if h.db == nil { + return true, nil + } + isTest, err := models.IsTestCohort(c.Context(), h.db, teamID) + if err != nil { + // Fail open: a real customer's charge must not be blocked by a DB blip. + slog.Warn("billing.test_cohort_check_failed_open", + "error", err, "team_id", teamID, "action", action, + "request_id", middleware.GetRequestID(c)) + return true, nil + } + if !isTest { + return true, nil + } + slog.Info("billing.test_cohort_skip", + "team_id", teamID, "action", action, + "request_id", middleware.GetRequestID(c)) + return false, respondError(c, fiber.StatusForbidden, errCodeSyntheticTestCohort, + "This is a synthetic test-cohort team and cannot start a real billing charge.") +} + // CreateCheckoutAPI handles POST /api/v1/billing/checkout (and the legacy // alias POST /billing/checkout). Creates a Razorpay subscription and returns // the hosted payment short_url plus the subscription_id. @@ -690,6 +739,13 @@ func (h *BillingHandler) CreateCheckoutAPI(c *fiber.Ctx) error { return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required") } + // Synthetic-cohort skip-guard (migration 067): a test-cohort team must + // never reach the real Razorpay subscription-create call. Checked before + // the email gate / Redis dedup so a synthetic call consumes nothing. + if ok, errResp := h.rejectIfTestCohort(c, teamID, "checkout"); !ok { + return errResp + } + // Email-verified gate (migration 052): a /claim-created account must // verify its email before it can start a paid checkout. Checked before // the Redis dedup so an unverified caller never consumes a dedup slot. @@ -3173,6 +3229,11 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { if err != nil { return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Valid session token required") } + // Synthetic-cohort skip-guard (migration 067) — same as checkout: a + // test-cohort team must never reach the real Razorpay change-plan call. + if ok, errResp := h.rejectIfTestCohort(c, teamID, "change_plan"); !ok { + return errResp + } // Email-verified gate (migration 052) — same gate as checkout: a // /claim-created account must verify its email before changing plans. if ok, errResp := h.requireVerifiedEmail(c, "change_plan"); !ok { diff --git a/internal/handlers/billing_test_cohort_test.go b/internal/handlers/billing_test_cohort_test.go new file mode 100644 index 00000000..7164fee1 --- /dev/null +++ b/internal/handlers/billing_test_cohort_test.go @@ -0,0 +1,179 @@ +package handlers_test + +// billing_test_cohort_test.go — W0 / PR-1 (cohort-isolation foundation). +// +// Proves the api-side synthetic-cohort skip-guard on the two self-serve +// charge-initiation handlers (CreateCheckoutAPI, ChangePlanAPI): a team with +// teams.is_test_cohort=true (migration 067) is rejected with a deterministic +// 403 synthetic_test_cohort BEFORE any Razorpay call, while a normal team +// sails past the guard. See +// docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/email" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// errCodeSyntheticTestCohort mirrors the unexported handler constant — the +// stable wire code the synthetic runner asserts on. +const errCodeSyntheticTestCohort = "synthetic_test_cohort" + +func cohortNeedsDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("billing_test_cohort_test: TEST_DATABASE_URL not set — skipping integration test") + } + return testhelpers.SetupTestDB(t) +} + +// cohortBillingApp wires both charge-initiation endpoints with a fake-auth +// middleware that injects only team_id (no user_id, so the email-verify gate +// fails OPEN — isolating the cohort guard as the only blocker under test). +// Razorpay creds are intentionally empty so a normal team that passes the +// guard halts at billing_not_configured (503) without any network call. +func cohortBillingApp(t *testing.T, db *sql.DB, teamID string) *fiber.App { + t.Helper() + cfg := &config.Config{JWTSecret: testhelpers.TestJWTSecret} // no Razorpay creds + bh := handlers.NewBillingHandler(db, cfg, email.NewNoop()) + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error"}) + }, + }) + app.Use(func(c *fiber.Ctx) error { + if teamID != "" { + c.Locals(middleware.LocalKeyTeamID, teamID) + } + return c.Next() + }) + app.Post("/api/v1/billing/checkout", bh.CreateCheckoutAPI) + app.Post("/api/v1/billing/change-plan", bh.ChangePlanAPI) + return app +} + +func cohortPost(t *testing.T, app *fiber.App, path, body string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + var out map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp.StatusCode, out +} + +// TestCheckout_TestCohortGuard_FailsOpenOnDBError: a DB blip on the cohort +// lookup must NOT block a real customer's checkout. The guard fails open +// (treats the lookup error as "not a test cohort") and execution proceeds +// past it — so the response is anything OTHER than synthetic_test_cohort. +// Uses sqlmock so the error branch is deterministic and DB-independent. +func TestCheckout_TestCohortGuard_FailsOpenOnDBError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + teamID := uuid.NewString() + mock.ExpectQuery("SELECT is_test_cohort FROM teams WHERE id"). + WillReturnError(errors.New("db blip")) + + app := cohortBillingApp(t, db, teamID) // no Razorpay creds → halts at not_configured + status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`) + + assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"], + "a DB error on the cohort lookup must fail OPEN, not block the customer") + assert.NotEqual(t, http.StatusForbidden, status) +} + +// TestCheckout_TestCohortTeam_Rejected: a synthetic team is 403'd with the +// distinct code on the checkout path before any Razorpay call. +func TestCheckout_TestCohortTeam_Rejected(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`) + + assert.Equal(t, http.StatusForbidden, status) + assert.Equal(t, errCodeSyntheticTestCohort, body["error"]) +} + +// TestChangePlan_TestCohortTeam_Rejected: same guard on the change-plan path. +func TestChangePlan_TestCohortTeam_Rejected(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + require.NoError(t, models.SetTestCohort(context.Background(), db, uuid.MustParse(teamID), true)) + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/change-plan", `{"target_plan":"pro"}`) + + assert.Equal(t, http.StatusForbidden, status) + assert.Equal(t, errCodeSyntheticTestCohort, body["error"]) +} + +// TestCheckout_NormalTeam_NotSkipped: a normal (default is_test_cohort=false) +// team is NOT caught by the guard — it passes through and halts later +// (billing_not_configured, since Razorpay creds are empty). The assertion is +// that the response is anything OTHER than synthetic_test_cohort, proving the +// guard is cohort-specific and inert for real teams. +func TestCheckout_NormalTeam_NotSkipped(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") // is_test_cohort defaults false + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/checkout", `{"plan":"pro"}`) + + assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"], + "a normal team must NOT be rejected by the synthetic-cohort guard") + assert.NotEqual(t, http.StatusForbidden, status, + "a normal team must pass the guard (halts later at billing_not_configured)") +} + +// TestChangePlan_NormalTeam_NotSkipped: change-plan twin of the above. +func TestChangePlan_NormalTeam_NotSkipped(t *testing.T) { + db, cleanup := cohortNeedsDB(t) + defer cleanup() + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + + app := cohortBillingApp(t, db, teamID) + status, body := cohortPost(t, app, "/api/v1/billing/change-plan", `{"target_plan":"pro"}`) + + assert.NotEqual(t, errCodeSyntheticTestCohort, body["error"], + "a normal team must NOT be rejected by the synthetic-cohort guard") + _ = status +} diff --git a/internal/models/is_test_cohort_db_test.go b/internal/models/is_test_cohort_db_test.go new file mode 100644 index 00000000..4d2e0975 --- /dev/null +++ b/internal/models/is_test_cohort_db_test.go @@ -0,0 +1,59 @@ +package models_test + +// is_test_cohort_db_test.go — DB-backed smoke for migration 067 +// (teams.is_test_cohort). Asserts the column exists, defaults to false, is +// scanned onto the Team struct, and round-trips through SetTestCohort / +// IsTestCohort. Skips when TEST_DATABASE_URL is unset so the suite runs +// cleanly without Postgres. + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +func TestIsTestCohort_MigrationSmokeAndRoundTrip(t *testing.T) { + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("TEST_DATABASE_URL not set; skipping integration test") + } + ctx := context.Background() + db, clean := testhelpers.SetupTestDB(t) + defer clean() + + // A freshly-created team defaults to is_test_cohort = false (inert by + // default — behaviour unchanged for every real team). The dedicated + // IsTestCohort lookup is the single read path: the column is intentionally + // NOT scanned into the main Team struct, to keep the cohort flag off the + // widely-mocked GetTeamByID/CreateTeam/GetTeamByRazorpaySubscriptionID + // SELECTs (which would otherwise force a 16-file sqlmock resync). + team, err := models.CreateTeam(ctx, db, "cohort-smoke") + require.NoError(t, err) + + // IsTestCohort helper reports the default. + isTest, err := models.IsTestCohort(ctx, db, team.ID) + require.NoError(t, err) + require.False(t, isTest) + + // Flip it via the seeder setter and confirm the helper observes the new value. + require.NoError(t, models.SetTestCohort(ctx, db, team.ID, true)) + + isTest, err = models.IsTestCohort(ctx, db, team.ID) + require.NoError(t, err) + require.True(t, isTest) + + // SetTestCohort on a non-existent team returns ErrTeamNotFound. + err = models.SetTestCohort(ctx, db, uuid.New(), true) + var notFound *models.ErrTeamNotFound + require.ErrorAs(t, err, ¬Found) + + // IsTestCohort on a non-existent team is (false, nil). + isTest, err = models.IsTestCohort(ctx, db, uuid.New()) + require.NoError(t, err) + require.False(t, isTest) +} diff --git a/internal/models/is_test_cohort_test.go b/internal/models/is_test_cohort_test.go new file mode 100644 index 00000000..1eafef59 --- /dev/null +++ b/internal/models/is_test_cohort_test.go @@ -0,0 +1,87 @@ +package models + +// is_test_cohort_test.go — white-box sqlmock coverage for the cohort-isolation +// helpers added in migration 067 (W0 / PR-1): IsTestCohort + SetTestCohort. +// Every DB-error / no-rows / rows-affected branch is driven here without a real +// Postgres; the DB-backed smoke (default + real scan) lives in +// is_test_cohort_db_test.go (package models_test). + +import ( + "context" + "errors" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestIsTestCohort_Branches(t *testing.T) { + ctx := context.Background() + id := uuid.New() + + // true value scans through. + db, mock := newMock(t) + mock.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(true)) + got, err := IsTestCohort(ctx, db, id) + require.NoError(t, err) + require.True(t, got) + + // false value scans through. + db2, mock2 := newMock(t) + mock2.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`). + WillReturnRows(sqlmock.NewRows([]string{"is_test_cohort"}).AddRow(false)) + got, err = IsTestCohort(ctx, db2, id) + require.NoError(t, err) + require.False(t, got) + + // no rows → (false, nil) — missing team is not a test cohort. + db3, mock3 := newMock(t) + mock3.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`).WillReturnError(errNoRows()) + got, err = IsTestCohort(ctx, db3, id) + require.NoError(t, err) + require.False(t, got) + + // query error → wrapped error, false. + db4, mock4 := newMock(t) + mock4.ExpectQuery(`SELECT is_test_cohort FROM teams WHERE id`).WillReturnError(errors.New("boom")) + got, err = IsTestCohort(ctx, db4, id) + require.ErrorContains(t, err, "boom") + require.False(t, got) +} + +func TestSetTestCohort_Branches(t *testing.T) { + ctx := context.Background() + id := uuid.New() + + // success — one row updated. + db, mock := newMock(t) + mock.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(true, id). + WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, SetTestCohort(ctx, db, id, true)) + + // zero rows → ErrTeamNotFound. + db2, mock2 := newMock(t) + mock2.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(false, id). + WillReturnResult(sqlmock.NewResult(0, 0)) + err := SetTestCohort(ctx, db2, id, false) + var notFound *ErrTeamNotFound + require.ErrorAs(t, err, ¬Found) + + // exec error → wrapped error. + db3, mock3 := newMock(t) + mock3.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(true, id). + WillReturnError(errors.New("boom")) + require.ErrorContains(t, SetTestCohort(ctx, db3, id, true), "boom") + + // RowsAffected error → wrapped "rows" error. + db4, mock4 := newMock(t) + mock4.ExpectExec(`UPDATE teams SET is_test_cohort`). + WithArgs(true, id). + WillReturnResult(sqlmock.NewErrorResult(errors.New("ra-boom"))) + require.ErrorContains(t, SetTestCohort(ctx, db4, id, true), "rows") +} diff --git a/internal/models/team.go b/internal/models/team.go index bf23c381..349bc4c7 100644 --- a/internal/models/team.go +++ b/internal/models/team.go @@ -339,6 +339,54 @@ func UpdatePlanTier(ctx context.Context, db *sql.DB, teamID uuid.UUID, tier stri return nil } +// IsTestCohort reports whether a team is part of the synthetic-monitoring test +// cohort (migration 067). It is the single lookup every api-side charge / +// conversion path keys off to no-op for a synthetic team so that continuous +// synthetic traffic never pollutes the real funnel / billing / email surfaces +// (W0 / PR-1 — see TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6). +// +// A missing team is treated as NOT a test cohort: callers reach this only with +// an already-authenticated team id, and a stricter ErrNoRows surface here would +// just turn a 404 into a 500 on the chargeable path. The flag is inert by +// default — every real team is false until a seeder sets it true via +// SetTestCohort — so the common case returns false with one indexed lookup. +func IsTestCohort(ctx context.Context, db *sql.DB, teamID uuid.UUID) (bool, error) { + var isTest bool + err := db.QueryRowContext(ctx, ` + SELECT is_test_cohort FROM teams WHERE id = $1 + `, teamID).Scan(&isTest) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("models.IsTestCohort: %w", err) + } + return isTest, nil +} + +// SetTestCohort flips teams.is_test_cohort for a single team. It is the only +// writer of the cohort flag and is intended for the worker-side synthetic +// seeder job (flow_synthetic_seed) — there is deliberately NO public HTTP +// surface that mutates it (a self-serve "mark my team as test" would let any +// caller opt out of billing/quota). Idempotent: setting the same value twice +// is a harmless no-op UPDATE. +func SetTestCohort(ctx context.Context, db *sql.DB, teamID uuid.UUID, isTest bool) error { + res, err := db.ExecContext(ctx, ` + UPDATE teams SET is_test_cohort = $1 WHERE id = $2 + `, isTest, teamID) + if err != nil { + return fmt.Errorf("models.SetTestCohort: %w", err) + } + rows, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("models.SetTestCohort rows: %w", err) + } + if rows == 0 { + return &ErrTeamNotFound{ID: teamID} + } + return nil +} + // UpgradeTeamAllTiers atomically upgrades the team tier and promotes every // active resource, deployment, and stack owned by that team. All four updates // run inside a single transaction so a partial failure (e.g. ElevateDeployments diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index d2d0275c..f3ecd783 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -325,6 +325,10 @@ func runMigrations(t *testing.T, db *sql.DB) { `ALTER TABLE deployments ADD COLUMN IF NOT EXISTS git_token_enc TEXT NOT NULL DEFAULT ''`, // teams.default_deployment_ttl_policy — Wave FIX-J team preference. `ALTER TABLE teams ADD COLUMN IF NOT EXISTS default_deployment_ttl_policy TEXT NOT NULL DEFAULT 'auto_24h'`, + // teams.is_test_cohort — migration 067 (W0 synthetic-monitoring cohort + // isolation). Inert by default; seeder-set true on durable test teams so + // charge/funnel/email paths can exclude them. + `ALTER TABLE teams ADD COLUMN IF NOT EXISTS is_test_cohort BOOLEAN NOT NULL DEFAULT false`, // 012_audit_log — per-team event stream consumed by the dashboard's // Recent Activity feed AND by the admin customer-detail endpoint. `CREATE TABLE IF NOT EXISTS audit_log (