From bc3b68154d8fb6734a53447b620df07947d8ce0d Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Sun, 27 Sep 2026 13:21:32 +0530 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20initialize=20session=20branch=20?= =?UTF-8?q?=E2=80=94=20Impove=20and=20seamless=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 1ff5c7abec2a87a6bbde16511c51c0ebe3a48866 Mon Sep 17 00:00:00 2001 From: Rajan Maurya Date: Wed, 7 Oct 2026 00:27:49 +0530 Subject: [PATCH 2/2] chore(source): update dashboard/__tests__/api/products/create.test.ts dashboard/__tests__/lib/drift-no-test-credential.test.ts dashboard/app/api/pricing/route.ts --- .../__tests__/api/products/create.test.ts | 3 + .../lib/drift-no-test-credential.test.ts | 13 +- .../lib/drift-stripe-error-surfaced.test.ts | 113 ++++++ .../lib/stripe-id-mode-scope.test.ts | 141 +++++++ .../lib/sync-state-durability.test.ts | 114 ++++++ dashboard/app/api/pricing/route.ts | 4 +- dashboard/app/api/products/[id]/route.ts | 2 + dashboard/app/api/products/[id]/sync/route.ts | 4 +- .../api/products/sync-to-providers/route.ts | 4 +- .../app/api/v1/products/[id]/sync/route.ts | 2 + dashboard/lib/drift-detectors.ts | 27 +- dashboard/lib/stripe-route-helper.ts | 179 +++++++-- dashboard/lib/sync-drain.ts | 4 + idea-layer/state/AGENT_AWARENESS.jsonl | 22 -- idea-layer/state/BEHAVIOR_VERDICTS.yaml | 52 --- .../PAYCRAFT_PHASE4_SECRETS_VERDICT.yaml | 10 - .../state/PAYCRAFT_PHASE4_VERDICTS.yaml | 370 ------------------ infra/deploy/deploy.sh | 23 +- supabase/functions/config/index.ts | 27 +- .../migrations/147_stripe_ids_mode_scoped.sql | 129 ++++++ 20 files changed, 739 insertions(+), 504 deletions(-) create mode 100644 dashboard/__tests__/lib/drift-stripe-error-surfaced.test.ts create mode 100644 dashboard/__tests__/lib/stripe-id-mode-scope.test.ts create mode 100644 dashboard/__tests__/lib/sync-state-durability.test.ts delete mode 100644 idea-layer/state/AGENT_AWARENESS.jsonl delete mode 100644 idea-layer/state/BEHAVIOR_VERDICTS.yaml delete mode 100644 idea-layer/state/PAYCRAFT_PHASE4_SECRETS_VERDICT.yaml delete mode 100644 idea-layer/state/PAYCRAFT_PHASE4_VERDICTS.yaml create mode 100644 supabase/migrations/147_stripe_ids_mode_scoped.sql diff --git a/dashboard/__tests__/api/products/create.test.ts b/dashboard/__tests__/api/products/create.test.ts index 9e5ff5cf..ff6b0084 100644 --- a/dashboard/__tests__/api/products/create.test.ts +++ b/dashboard/__tests__/api/products/create.test.ts @@ -48,10 +48,13 @@ const stripeSyncSpy = jest.fn(async (supabase: any, opts: any) => { // Real helper short-circuits if tenant has no Stripe connection — we honor // the same gate here by checking the test-controlled flag. if (!(globalThis as any).__stripeConnected) return + // p_mode is required since migration 147 — the real helper writes once per synced mode. A mock + // that omits it would keep passing while the real call regressed to the mode-blind 3-arg shape. await supabase.rpc("tenant_products_set_stripe_ids", { p_id: opts.productId, p_stripe_product_id: "prod_synced", p_stripe_price_id_by_currency: { USD: "price_synced_USD" }, + p_mode: "live", }) await supabase.rpc("tenant_providers_set_payment_links", { p_tenant_id: opts.tenantId, diff --git a/dashboard/__tests__/lib/drift-no-test-credential.test.ts b/dashboard/__tests__/lib/drift-no-test-credential.test.ts index 43f7a5a2..cc502041 100644 --- a/dashboard/__tests__/lib/drift-no-test-credential.test.ts +++ b/dashboard/__tests__/lib/drift-no-test-credential.test.ts @@ -53,8 +53,17 @@ describe("detectNoTestCredential", () => { expect(out).toHaveLength(1) expect(out[0].kind).toBe("no-test-credential") expect(out[0].subject).toBe("provider:stripe") - // QUOTES the offending value — a finding you cannot confirm is noise. - expect(out[0].detail).toContain("sk_live_abc") + // IDENTIFIES the offending value — a finding you cannot confirm is noise. It used to quote the + // key in full; it is now masked to the last 6 characters, which keeps the finding confirmable + // while keeping a credential out of an API response. + // + // The narrowing is deliberate, and this fixture is the reason: `key_id` is not always + // publishable. `sk_live_abc` here is SECRET tier, and this `detail` is returned by + // /api/sync/drift — it reaches logs, CI output and agent transcripts. One such detail leaked a + // live publishable key into a transcript on 2026-10-06; the same code path would have leaked a + // secret one for a provider whose key_id looks like this fixture. + expect(out[0].detail).toContain("ve_abc") + expect(out[0].detail).not.toContain("sk_live_abc") // And names a concrete next action, not a description of the problem. expect(out[0].action_hint).toMatch(/TEST-mode key/i) expect(out[0].subject_id).toBe("stripe") diff --git a/dashboard/__tests__/lib/drift-stripe-error-surfaced.test.ts b/dashboard/__tests__/lib/drift-stripe-error-surfaced.test.ts new file mode 100644 index 00000000..536fa23a --- /dev/null +++ b/dashboard/__tests__/lib/drift-stripe-error-surfaced.test.ts @@ -0,0 +1,113 @@ +/** + * A "not readable at Stripe" finding must carry the REASON Stripe gave. + * + * WHY THIS TEST EXISTS + * The readback was `try { products.retrieve(id) } catch { push("not readable at Stripe") }`. A bare + * `catch {}` collapses three different root causes with three different remedies into one string: + * + * resource_missing → the id is absent from the CONNECTED account (deleted, or created under + * a different account than the key now stored) — re-sync creates it + * authentication_error → the key is revoked/rotated — re-syncing cannot help, fix the credential + * a network fault → nothing is wrong with the data at all + * + * Measured 2026-10-06: all 7 products across tenants cappy and reels-downloader reported + * "not readable at Stripe" and the output contained NOTHING to act on. Two hours of diagnosis went + * into questions the discarded exception would have answered in one line. + * + * Also asserted: the `no-test-credential` detail must not emit a key VALUE. That `detail` is + * returned by /api/sync/drift and lands in logs, CI output and agent transcripts — one did leak a + * live publishable key into a transcript on 2026-10-06. Publishable keys are low-tier, but the same + * string shape is reused for providers whose `key_id` is not publishable. + */ + +import { detectNoTestCredential, detectProductMissingAtProvider } from "@/lib/drift-detectors" + +const TENANT = "ba973ad0-8788-4c0f-89c8-1ff9533fa79f" + +const retrieve = jest.fn() +jest.mock("@/lib/stripe-client", () => ({ + getConnectedStripeClient: jest.fn(async () => ({ products: { retrieve: (id: string) => retrieve(id) } })), +})) + +/** `.select().eq().eq()` chainable AND awaitable — only what the detectors use. */ +function fakeSupabase(rows: unknown[]) { + const thenable: Record = { + select: () => thenable, + eq: () => thenable, + then: (res: (v: { data: unknown[] | null; error: unknown }) => unknown) => + res({ data: rows, error: null }), + } + return { from: () => thenable } as never +} + +const product = { + id: "47ddeaf3-eecb-4903-a998-96c630c80b17", + sku: "cappy_plus_monthly", + stripe_product_id: "prod_VJMNS10q3sZyzF", + package_id: null, +} + +beforeEach(() => retrieve.mockReset()) + +describe("detectProductMissingAtProvider — the Stripe reason survives", () => { + it("names resource_missing AND points at the account mismatch, not just 'not readable'", async () => { + const err: any = new Error("No such product: 'prod_VJMNS10q3sZyzF'") + err.code = "resource_missing" + retrieve.mockRejectedValue(err) + + const [f] = await detectProductMissingAtProvider(fakeSupabase([product]), TENANT) + expect(f.detail).toContain("not readable at Stripe") + expect(f.detail).toContain("[resource_missing]") + expect(f.detail).toContain("No such product") + // The remedy differs per cause, so the hint must too. + expect(f.action_hint).toMatch(/different account|deleted/i) + }) + + it("surfaces an authentication failure as itself — re-syncing would not fix it", async () => { + const err: any = new Error("Invalid API Key provided: sk_live_***") + err.type = "authentication_error" + retrieve.mockRejectedValue(err) + + const [f] = await detectProductMissingAtProvider(fakeSupabase([product]), TENANT) + expect(f.detail).toContain("[authentication_error]") + expect(f.detail).toContain("Invalid API Key") + }) + + it("still reports a bare failure with no code, rather than throwing", async () => { + retrieve.mockRejectedValue(new Error("socket hang up")) + const [f] = await detectProductMissingAtProvider(fakeSupabase([product]), TENANT) + expect(f.detail).toContain("socket hang up") + expect(f.kind).toBe("product-missing-at-provider") + }) + + it("reports nothing when the product IS readable and active", async () => { + retrieve.mockResolvedValue({ id: product.stripe_product_id, active: true }) + expect(await detectProductMissingAtProvider(fakeSupabase([product]), TENANT)).toHaveLength(0) + }) +}) + +describe("detectNoTestCredential — never emits a key value", () => { + it("masks the live key to its last 6 characters", async () => { + const rows = [ + { + provider: "stripe", + is_active: true, + // Deliberately SHORT + obviously synthetic: a >=20-char body matches the SV32 + // staged-secret guard (pk_(live|test)_[A-Za-z0-9]{20,}) and a real key's prefix has no + // business in a committed fixture. + live_key_id: "pk_live_FIXTUREaVcn", + test_key_id: null, + test_payment_links: {}, + provider_accounts: { config: {} }, + }, + ] + const [f] = await detectNoTestCredential(fakeSupabase(rows), TENANT) + expect(f).toBeDefined() + // The whole key must not appear anywhere in the finding. + const blob = JSON.stringify(f) + expect(blob).not.toContain("pk_live_FIXTUREaVcn") + expect(blob).not.toContain("FIXTURE") + // Identity is still legible so an operator can tell WHICH key it means. + expect(f.detail).toContain("REaVcn") // slice(-6) of the fixture + }) +}) diff --git a/dashboard/__tests__/lib/stripe-id-mode-scope.test.ts b/dashboard/__tests__/lib/stripe-id-mode-scope.test.ts new file mode 100644 index 00000000..9cbf26d5 --- /dev/null +++ b/dashboard/__tests__/lib/stripe-id-mode-scope.test.ts @@ -0,0 +1,141 @@ +import fs from "fs" +import path from "path" + +/** + * Stripe product + price ids are MODE-SCOPED (migration 147). This is the sibling of + * `razorpay-plan-mode-scope.test.ts`; same defect, same shape, one provider later. + * + * Before it, `stripe_product_id` / `stripe_price_id_by_currency` were single columns written by + * whichever sync mode ran last. `stripeSyncProduct` builds `modes` live-first and recorded + * `lastResult` — the LAST iteration, i.e. TEST — so a routine sync on a both-keys tenant ended with + * test product and price ids in the fields LIVE checkout reads. + * + * MEASURED 2026-10-06, tenants cappy + reels-downloader, 7 products, from Stripe itself: + * + * No such product: 'prod_VJMNS10q3sZyzF'; a similar object exists in test mode, + * but a live mode key was used to make this request + * + * Live Stripe checkout on two shipped apps was pointed at test-mode objects. + * + * Source-level assertions, for the reason the Razorpay sibling gives: the defect is about WHICH + * FIELD each path touches, and a mocked unit test of either path passes whether or not the other + * agrees. + */ + +const ROOT = path.join(__dirname, "..", "..") +const read = (p: string) => fs.readFileSync(path.join(ROOT, p), "utf8") + +describe("the sync writes each mode to its own columns", () => { + const src = read("lib/stripe-route-helper.ts") + + it("passes p_mode to the setter", () => { + expect(src).toMatch(/tenant_products_set_stripe_ids[\s\S]{0,260}p_mode:/) + }) + + it("does not record ids from `lastResult`, which is whichever mode ran last", () => { + expect(src).not.toMatch(/p_stripe_product_id:\s*lastResult\.stripeProductId/) + expect(src).not.toMatch(/p_stripe_price_id_by_currency:\s*lastResult\.pricesByCurrency/) + }) + + it("keeps per-mode results apart", () => { + expect(src).toMatch(/stripeResultsByMode/) + }) + + it("seeds a test sync from the TEST existing-ids map", () => { + // Handing a test sync the live product id asks Stripe to update a live object with a test key. + expect(src).toMatch(/mode === "test"[\s\S]{0,160}existingStripeProductIdTest/) + }) +}) + +describe("every caller supplies both id sets", () => { + // Enumerated from disk: a caller passing only the live ids silently reverts the fix for whatever + // path it serves, and would fail nothing else here. + function walk(dir: string, out: string[] = []): string[] { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === "node_modules" || e.name === ".next" || e.name === ".open-next") continue + const p = path.join(dir, e.name) + if (e.isDirectory()) walk(p, out) + else if (e.name.endsWith(".ts")) out.push(p) + } + return out + } + + const callers = [...walk(path.join(ROOT, "lib")), ...walk(path.join(ROOT, "app"))] + .map((f) => ({ rel: path.relative(ROOT, f), src: fs.readFileSync(f, "utf8") })) + .filter((f) => f.src.includes("existingStripeProductId:")) + + it("finds the call sites (an empty sweep would pass everything below)", () => { + expect(callers.length).toBeGreaterThanOrEqual(1) + }) + + it.each(callers.map((c) => [c.rel, c]))("%s also passes the test ids", (_r, c: any) => { + expect(c.src).toMatch(/existingStripeProductIdTest:/) + }) +}) + +describe("the loader selects the columns the fix depends on", () => { + const src = read("lib/stripe-route-helper.ts") + // Omit them and `loaded.product.stripe_product_id_test` is undefined forever — the fix would be + // syntactically present and behaviourally absent, which is the worst of the three states. + it.each(["stripe_product_id_test", "stripe_price_id_by_currency_test", "live_stripe_ids_verified"])( + "selects %s", + (col) => expect(src).toContain(col), + ) +}) + +describe("/config serves the ids for the mode the caller is in", () => { + const src = fs.readFileSync( + path.join(ROOT, "..", "supabase", "functions", "config", "index.ts"), + "utf8", + ) + + it("threads the resolved mode into the binding resolver", () => { + expect(src).toMatch(/storeBindingForChain\([\s\S]{0,120}isTestMode\)/) + }) + + it("reads the TEST price map for a test caller", () => { + expect(src).toMatch(/isTest\s*\n?\s*\?\s*p\.stripe_price_id_by_currency_test/) + }) + + it("does not fall back to the other mode when its own is missing", () => { + // A cross-mode fallback would hand a test build live price ids — exactly the bug. /config + // returning null makes the client BLOCK, which is the correct outcome. + expect(src).not.toMatch(/stripe_price_id_by_currency_test\s*\?\?\s*p\.stripe_price_id_by_currency\b/) + expect(src).not.toMatch(/stripe_product_id_test\s*\?\?\s*p\.stripe_product_id\b/) + }) +}) + +describe("migration 147", () => { + const sql = fs.readFileSync( + path.join(ROOT, "..", "supabase", "migrations", "147_stripe_ids_mode_scoped.sql"), + "utf8", + ) + + it("drops the three-argument setter instead of leaving it as an overload", () => { + // Left in place, PostgREST would resolve an un-updated three-arg caller to it silently, and + // that caller would keep writing the live columns from a test sync. + expect(sql).toMatch(/DROP FUNCTION IF EXISTS public\.tenant_products_set_stripe_ids\(uuid, text, jsonb\)/) + }) + + it("refuses an unrecognised mode rather than defaulting to live", () => { + expect(sql).toMatch(/invalid_mode/) + expect(sql).not.toMatch(/p_mode\s+TEXT\s+DEFAULT/) + }) + + it("marks legacy live ids unverified rather than trusting them", () => { + expect(sql).toMatch(/live_stripe_ids_verified/) + }) + + it("adds both test columns and leaves the live ones in place", () => { + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS stripe_product_id_test/) + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS stripe_price_id_by_currency_test/) + // 141's reasoning: clearing the legacy values would break live checkout for every row that DOES + // hold a live id, trading a latent risk for a certain outage. + expect(sql).not.toMatch(/UPDATE tenant_products\s+SET\s+stripe_product_id\s*=\s*NULL/i) + }) + + it("is idempotent, per this repo's migration rule", () => { + expect(sql).toMatch(/ADD COLUMN IF NOT EXISTS/) + expect(sql).toMatch(/CREATE OR REPLACE FUNCTION/) + }) +}) diff --git a/dashboard/__tests__/lib/sync-state-durability.test.ts b/dashboard/__tests__/lib/sync-state-durability.test.ts new file mode 100644 index 00000000..229fe2db --- /dev/null +++ b/dashboard/__tests__/lib/sync-state-durability.test.ts @@ -0,0 +1,114 @@ +/** + * Regression: a product-sync run must never leave a row whose recorded state is + * indistinguishable from a successful one. + * + * The incident (mbs/cappy, 2026-10-06). `runProductSync` opened with + * `tenant_products_set_sync_state(p_status: "syncing", p_state: null)`, and migration 078's RPC + * merges with `CASE WHEN p_state IS NULL THEN sync_state ELSE sync_state || p_state END` — so the + * null PRESERVED the previous run's per-provider map. The route then died before its terminal + * write (the drain client timed out at 90s while the server was still fanning out), leaving + * `cappy_plus_guardian` stranded: + * + * sync_status = 'syncing' (14+ minutes, no process alive) + * sync_state = {"stripe":{"status":"synced"}, …} ← from 2026-09-23, read as fresh + * synced_at = 2026-09-23 + * + * A dead run that presents three-weeks-stale success is worse than either a failure or an empty + * state: the operator (and an agent reading the row) concludes the providers finished. + * + * The second half of the same incident: all 7 products across cappy AND reels-downloader carried + * `sync_state.stripe.status='synced'` while every `stripe_product_id` was unreadable with the + * tenant's connected live key. A provider helper returning ok proves the write was accepted by the + * client we hold — not that the object is retrievable afterwards. + * + * These assert on the SOURCE of `runProductSync`, following the precedent set by + * `bulk-sync-classification.test.ts`: the function's failure modes live in control flow around five + * provider helpers plus a Stripe client, and a mock deep enough to exercise them asserts the mock + * rather than the contract. The contract is what must not regress. + */ + +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import { classifyProvider, type ProviderSyncEntry } from "@/lib/stripe-route-helper" + +const SRC = readFileSync(join(process.cwd(), "lib/stripe-route-helper.ts"), "utf8") + +/** Body of `runProductSync`, from its signature to the start of the next top-level declaration. */ +function runProductSyncBody(): string { + const start = SRC.indexOf("export async function runProductSync(") + expect(start).toBeGreaterThan(-1) + const after = SRC.indexOf("\n/**", start) + return SRC.slice(start, after > -1 ? after : undefined) +} + +describe("runProductSync — sync_state durability", () => { + it("opens with a NON-NULL p_state so a dead run cannot present the previous run's success", () => { + const body = runProductSyncBody() + const opening = body.slice(0, body.indexOf('p_status: "syncing"') + 200) + + // The precise regression: `p_state: null` on the opening call. + expect(opening).not.toMatch(/p_status:\s*"syncing",\s*\n?\s*p_state:\s*null/) + expect(opening).toMatch(/p_state:\s*inFlight/) + }) + + it("marks exactly the providers THIS run touches as in-flight, leaving the rest merged", () => { + const body = runProductSyncBody() + // Built from `keys` (which honours onlyProvider), never from the full runner set — a + // single-provider retry must not wipe the other providers' recorded state, which is the whole + // reason 078's RPC merges. + expect(body).toMatch(/const inFlight = Object\.fromEntries\(\s*\n?\s*keys\.map\(/) + }) + + it("writes a terminal state when the provider fan-out THROWS, instead of leaving 'syncing'", () => { + const body = runProductSyncBody() + expect(body).toMatch(/catch\s*\(e: any\)\s*\{/) + // The catch must both record failure and rethrow — swallowing would report a clean run. + const caught = body.slice(body.indexOf("catch (e: any)")) + expect(caught).toMatch(/p_status:\s*"failed"/) + expect(caught).toMatch(/throw e/) + }) + + it("READS BACK the stripe product before letting 'synced' stand", () => { + const body = runProductSyncBody() + expect(body).toMatch(/providers\.stripe\?\.status === "synced"/) + expect(body).toMatch(/verifyStripeProduct\(/) + + // The readback must be able to DOWNGRADE the entry, not merely log. + const verify = SRC.slice(SRC.indexOf("async function verifyStripeProduct(")) + expect(verify).toMatch(/products\.retrieve\(/) + expect(verify).toMatch(/status: "failed"/) + // It must keep the provider's error message: a bare `catch {}` collapses "deleted", + // "wrong account" and "revoked key" into one unreadable, which is what made the + // cappy/reels failure undiagnosable from drift output alone. + expect(verify).toMatch(/e\?\.message/) + }) + + it("never rolls an in-flight provider up to 'synced'", () => { + const body = runProductSyncBody() + // `syncing` is non-terminal, so a leftover in-flight entry must degrade the rollup. + expect(body).toMatch(/v\.status === "draft" \|\| v\.status === "syncing"/) + }) +}) + +describe("ProviderSyncEntry — 'syncing' is a first-class non-terminal status", () => { + it("classifyProvider still never INVENTS a syncing entry", () => { + // The in-flight marker is written by runProductSync only. The classifier maps real provider + // results, so it must keep returning terminal statuses. + const cases: Array[0]> = [ + undefined, + { skipped: true, reason: "Stripe is not connected" }, + { error: "not connected" }, + { error: "boom" }, + { warning: "offer DRAFT until published" }, + { ok: true }, + ] + const got = cases.map((c) => classifyProvider(c).status) + expect(got).not.toContain("syncing") + }) + + it("admits 'syncing' in the type so the marker needs no cast", () => { + const entry: ProviderSyncEntry = { status: "syncing", reason: "sync in progress" } + expect(entry.status).toBe("syncing") + }) +}) diff --git a/dashboard/app/api/pricing/route.ts b/dashboard/app/api/pricing/route.ts index a9ea843a..fc742c18 100644 --- a/dashboard/app/api/pricing/route.ts +++ b/dashboard/app/api/pricing/route.ts @@ -82,7 +82,7 @@ export async function POST(req: NextRequest) { const { data: product } = await supabase .from("tenant_products") .select( - "id, sku, type, display_name, interval, base_price_cents, base_currency, stripe_product_id, stripe_price_id_by_currency, razorpay_plan_id_by_currency, razorpay_plan_id_by_currency_test", + "id, sku, type, display_name, interval, base_price_cents, base_currency, stripe_product_id, stripe_price_id_by_currency, stripe_product_id_test, stripe_price_id_by_currency_test, live_stripe_ids_verified, razorpay_plan_id_by_currency, razorpay_plan_id_by_currency_test", ) .eq("tenant_id", tenant.id) .eq("id", productId) @@ -105,6 +105,8 @@ export async function POST(req: NextRequest) { body: syncBody, existingStripeProductId: product.stripe_product_id ?? undefined, existingPrices: product.stripe_price_id_by_currency ?? undefined, + existingStripeProductIdTest: product.stripe_product_id_test ?? undefined, + existingPricesTest: product.stripe_price_id_by_currency_test ?? undefined, }), razorpaySyncProduct(supabase, { tenantId: tenant.id, diff --git a/dashboard/app/api/products/[id]/route.ts b/dashboard/app/api/products/[id]/route.ts index f4d798af..a2d849b8 100644 --- a/dashboard/app/api/products/[id]/route.ts +++ b/dashboard/app/api/products/[id]/route.ts @@ -104,6 +104,8 @@ export async function PATCH( productName: body.display_name, existingStripeProductId: existing.stripe_product_id ?? undefined, existingPrices: existing.stripe_price_id_by_currency ?? undefined, + existingStripeProductIdTest: existing.stripe_product_id_test ?? undefined, + existingPricesTest: existing.stripe_price_id_by_currency_test ?? undefined, existingRazorpayPlanIds: existing.razorpay_plan_id_by_currency ?? undefined, existingRazorpayPlanIdsTest: existing.razorpay_plan_id_by_currency_test ?? undefined, }) diff --git a/dashboard/app/api/products/[id]/sync/route.ts b/dashboard/app/api/products/[id]/sync/route.ts index 48bbaebd..0954d970 100644 --- a/dashboard/app/api/products/[id]/sync/route.ts +++ b/dashboard/app/api/products/[id]/sync/route.ts @@ -43,7 +43,7 @@ export async function POST( const { data: product, error } = await supabase .from("tenant_products") .select( - "id, sku, type, display_name, store_description, interval, base_price_cents, base_currency, trial_enabled, trial_duration_days, trial_per_platform, stripe_product_id, stripe_price_id_by_currency, razorpay_plan_id_by_currency, razorpay_plan_id_by_currency_test, play_product_id, app_store_product_id", + "id, sku, type, display_name, store_description, interval, base_price_cents, base_currency, trial_enabled, trial_duration_days, trial_per_platform, stripe_product_id, stripe_price_id_by_currency, stripe_product_id_test, stripe_price_id_by_currency_test, live_stripe_ids_verified, razorpay_plan_id_by_currency, razorpay_plan_id_by_currency_test, play_product_id, app_store_product_id", ) .eq("tenant_id", tenant.id) .eq("id", params.id) @@ -76,6 +76,8 @@ export async function POST( onlyProvider: providerParam ?? undefined, existingStripeProductId: product.stripe_product_id ?? undefined, existingPrices: product.stripe_price_id_by_currency ?? undefined, + existingStripeProductIdTest: product.stripe_product_id_test ?? undefined, + existingPricesTest: product.stripe_price_id_by_currency_test ?? undefined, existingRazorpayPlanIds: product.razorpay_plan_id_by_currency ?? undefined, existingRazorpayPlanIdsTest: product.razorpay_plan_id_by_currency_test ?? undefined, existingPlayProductId: product.play_product_id ?? undefined, diff --git a/dashboard/app/api/products/sync-to-providers/route.ts b/dashboard/app/api/products/sync-to-providers/route.ts index 0b662f44..83acc394 100644 --- a/dashboard/app/api/products/sync-to-providers/route.ts +++ b/dashboard/app/api/products/sync-to-providers/route.ts @@ -140,7 +140,7 @@ async function loadFullProductBodies( const { data: products = [] } = await supabase .from("tenant_products") .select( - "id, sku, type, display_name, store_description, interval, base_price_cents, base_currency, trial_enabled, trial_duration_days, trial_per_platform, stripe_product_id, stripe_price_id_by_currency, razorpay_plan_id_by_currency, play_product_id, app_store_product_id", + "id, sku, type, display_name, store_description, interval, base_price_cents, base_currency, trial_enabled, trial_duration_days, trial_per_platform, stripe_product_id, stripe_price_id_by_currency, stripe_product_id_test, stripe_price_id_by_currency_test, live_stripe_ids_verified, razorpay_plan_id_by_currency, play_product_id, app_store_product_id", ) .eq("tenant_id", tenantId) .in("id", ids) @@ -224,6 +224,8 @@ export async function POST() { body, existingStripeProductId: body.stripe_product_id ?? undefined, existingPrices: body.stripe_price_id_by_currency ?? undefined, + existingStripeProductIdTest: body.stripe_product_id_test ?? undefined, + existingPricesTest: body.stripe_price_id_by_currency_test ?? undefined, }) // `stripeSyncProduct` returns structured status ({ok,skipped,error,reason}); the // return value used to be DISCARDED here and the outcome inferred from whether diff --git a/dashboard/app/api/v1/products/[id]/sync/route.ts b/dashboard/app/api/v1/products/[id]/sync/route.ts index cb248d55..295057f8 100644 --- a/dashboard/app/api/v1/products/[id]/sync/route.ts +++ b/dashboard/app/api/v1/products/[id]/sync/route.ts @@ -34,6 +34,8 @@ export async function POST(req: Request, { params }: { params: { id: string } }) onlyProvider: provider as never, existingStripeProductId: loaded.product.stripe_product_id ?? undefined, existingPrices: loaded.product.stripe_price_id_by_currency ?? undefined, + existingStripeProductIdTest: loaded.product.stripe_product_id_test ?? undefined, + existingPricesTest: loaded.product.stripe_price_id_by_currency_test ?? undefined, existingRazorpayPlanIds: loaded.product.razorpay_plan_id_by_currency ?? undefined, existingRazorpayPlanIdsTest: loaded.product.razorpay_plan_id_by_currency_test ?? undefined, existingPlayProductId: loaded.product.play_product_id ?? undefined, diff --git a/dashboard/lib/drift-detectors.ts b/dashboard/lib/drift-detectors.ts index cdca31e4..20e1d513 100644 --- a/dashboard/lib/drift-detectors.ts +++ b/dashboard/lib/drift-detectors.ts @@ -120,14 +120,29 @@ export async function detectProductMissingAtProvider( action_hint: `POST /api/products/${r.id}/sync?provider=stripe`, }) } - } catch { + } catch (e: any) { + // KEEP the Stripe error. A bare `catch {}` collapsed "No such product" (deleted, or created + // under a different account), "Invalid API Key" (rotated/revoked) and a transient network + // fault into one indistinguishable "not readable" — so an operator reading this finding could + // not tell a data problem from a credential problem. Measured 2026-10-06: all 7 products + // across two tenants reported "not readable" and the output contained nothing to act on. + const code = e?.code ?? e?.rawType ?? e?.type ?? null + const why = e?.message ? ` — ${e.message}` : "" out.push({ kind: "product-missing-at-provider", tenant_id: tenantId, subject: `product:${r.sku}`, subject_id: r.id, - detail: `stripe_product_id=${r.stripe_product_id} not readable at Stripe`, - action_hint: `POST /api/products/${r.id}/sync?provider=stripe`, + detail: + `stripe_product_id=${r.stripe_product_id} not readable at Stripe` + + (code ? ` [${code}]` : "") + + why, + action_hint: + code === "resource_missing" + ? `The id does not exist in the CONNECTED Stripe account — either it was deleted, or it ` + + `was created under a different account than the key now stored. Re-sync creates a new ` + + `product: POST /api/products/${r.id}/sync?provider=stripe` + : `POST /api/products/${r.id}/sync?provider=stripe`, }) } } @@ -501,7 +516,11 @@ export async function detectNoTestCredential( tenant_id: tenantId, subject: `provider:${r.provider}`, detail: - `${r.provider} has a live key (${liveKey}) and NO test key on its shared connection, so ` + + // Key IDENTITY, never the key: this `detail` is returned by /api/sync/drift and lands in + // logs, CI output and agent transcripts. Publishable keys are low-tier, but emitting any + // credential verbatim from an API response is the wrong default, and the same string shape + // is reused for providers whose key_id is not publishable. + `${r.provider} has a live key (…${String(liveKey).slice(-6)}) and NO test key on its shared connection, so ` + `product sync can only ` + `create LIVE products and \`test_payment_links\` stays empty. A \`pk_test_\` build resolves ` + `no link and cannot check out; testing this provider means transacting against REAL ` + diff --git a/dashboard/lib/stripe-route-helper.ts b/dashboard/lib/stripe-route-helper.ts index 41074cf3..372629f9 100644 --- a/dashboard/lib/stripe-route-helper.ts +++ b/dashboard/lib/stripe-route-helper.ts @@ -4,6 +4,7 @@ import { syncProductToCashfree } from "@/lib/cashfree-product-sync" import { syncProductToGooglePlay } from "@/lib/googleplay-product-sync" import { syncProductToAppStore } from "@/lib/appstore-product-sync" import { createClient } from "@/lib/supabase-server" +import { getConnectedStripeClient } from "@/lib/stripe-client" interface SyncOptions { tenantId: string @@ -11,6 +12,14 @@ interface SyncOptions { body: Record existingStripeProductId?: string existingPrices?: Record + /** + * TEST-mode product + price ids. Separate since 147, for the reason 141 records for Razorpay: + * handing the LIVE product id to a TEST sync asks Stripe to update a live object with a test key, + * which fails with "a similar object exists in test mode" — or, worse, succeeds against a + * same-named test object and re-registers it as live. + */ + existingStripeProductIdTest?: string + existingPricesTest?: Record existingRazorpayPlanIds?: Record /** TEST-mode plan ids. Separate since 141 — reusing the live map in a test sync re-registers a live plan id as test. */ existingRazorpayPlanIdsTest?: Record @@ -100,7 +109,11 @@ export async function stripeSyncProduct( supabase: ReturnType, opts: SyncOptions, ): Promise<{ ok?: boolean; skipped?: boolean; error?: string; reason?: string }> { - const { tenantId, productId, body, existingStripeProductId, existingPrices } = opts + const { + tenantId, productId, body, + existingStripeProductId, existingPrices, + existingStripeProductIdTest, existingPricesTest, + } = opts try { // Unified status check — recognizes BOTH the OAuth Connect path and the // Manual API keys path. The old code only checked tenant_stripe_connect @@ -143,6 +156,10 @@ export async function stripeSyncProduct( if (modes.length === 0) modes.push(connect.livemode ? "live" : "test") let lastResult: Awaited> | null = null + // Per-mode results, kept APART. `lastResult` alone is what produced the 147 defect: `modes` is + // live-first, so the final iteration is TEST, and recording "the last successful sync" wrote + // test product/price ids into the columns live checkout reads. + const stripeResultsByMode = new Map<"live" | "test", Awaited>>() const modeErrors: string[] = [] for (const mode of modes) { // One mode failing must not abandon the other — a missing test account is not a reason to @@ -155,12 +172,18 @@ export async function stripeSyncProduct( body.type, toStripeInterval(body.interval), prices, - { stripeProductId: existingStripeProductId, existingPrices }, + mode === "test" + ? { stripeProductId: existingStripeProductIdTest, existingPrices: existingPricesTest } + : { stripeProductId: existingStripeProductId, existingPrices }, trialDays, connect.source === "oauth" ? undefined : mode, supabase, ) lastResult = result + // OAuth tenants carry a single account whose livemode the token decides; `mode` is the loop + // variable in both cases and is the only thing that knows which Stripe account `result` + // came from, so attribute here rather than after the loop. + stripeResultsByMode.set(mode, result) // Nest under the product's SKU so multi-product tenants don't overwrite // each other's currency entries. Migration 070 introduced this RPC. await supabase.rpc("tenant_providers_merge_payment_links", { @@ -188,13 +211,20 @@ export async function stripeSyncProduct( } } - // Product/price ids are account-scoped; record the last successful sync's ids, preferring live - // because that is the account the shipped app bills through. - await supabase.rpc("tenant_products_set_stripe_ids", { - p_id: productId, - p_stripe_product_id: lastResult.stripeProductId, - p_stripe_price_id_by_currency: lastResult.pricesByCurrency, - }) + // Product/price ids are ACCOUNT-scoped, so each mode's ids are recorded against that mode + // (migration 147). This used to write `lastResult` into the live columns — and `modes` is + // live-first, so the last iteration is TEST. Measured 2026-10-06 on cappy + reels-downloader: + // 7 products whose live columns held test-mode ids, which Stripe rejected with "a similar + // object exists in test mode, but a live mode key was used". The RPC refuses a mode it does not + // recognise rather than defaulting to live. + for (const [m, res] of stripeResultsByMode) { + await supabase.rpc("tenant_products_set_stripe_ids", { + p_id: productId, + p_stripe_product_id: res.stripeProductId, + p_stripe_price_id_by_currency: res.pricesByCurrency, + p_mode: m, + }) + } return { ok: true } } catch (e: any) { console.error("[products] stripe sync failed:", e.message) @@ -680,7 +710,12 @@ async function renderTenantReviewScreenshot( } export interface ProviderSyncEntry { - status: "synced" | "draft" | "failed" | "skipped" + /** + * `syncing` is the IN-FLIGHT marker written before the provider call, so a run that dies + * mid-flight cannot leave the previous run's `synced` standing (see runProductSync). It is never + * a terminal value: every completed run overwrites it. + */ + status: "synced" | "draft" | "failed" | "skipped" | "syncing" error?: string warning?: string /** @@ -755,7 +790,7 @@ export async function loadProductSyncBody( const { data: product, error } = await supabase .from("tenant_products") .select( - "id, sku, type, display_name, store_description, interval, base_price_cents, base_currency, trial_enabled, trial_duration_days, trial_per_platform, stripe_product_id, stripe_price_id_by_currency, razorpay_plan_id_by_currency, razorpay_plan_id_by_currency_test, play_product_id, app_store_product_id", + "id, sku, type, display_name, store_description, interval, base_price_cents, base_currency, trial_enabled, trial_duration_days, trial_per_platform, stripe_product_id, stripe_price_id_by_currency, stripe_product_id_test, stripe_price_id_by_currency_test, live_stripe_ids_verified, razorpay_plan_id_by_currency, razorpay_plan_id_by_currency_test, play_product_id, app_store_product_id", ) .eq("tenant_id", tenantId) .eq("id", productId) @@ -786,12 +821,6 @@ export async function runProductSync( ): Promise { const { productId, onlyProvider } = opts - await supabase.rpc("tenant_products_set_sync_state", { - p_id: productId, - p_status: "syncing", - p_state: null, - }) - const runners: Record Promise> = { stripe: () => stripeSyncProduct(supabase, opts), razorpay: () => razorpaySyncProduct(supabase, opts), @@ -801,6 +830,28 @@ export async function runProductSync( } const keys = onlyProvider ? [onlyProvider] : Object.keys(runners) const product = opts.productName ?? "product" + + // Stamp `syncing` AND overwrite the per-provider entries for the providers THIS run touches. + // + // The opening call used to pass `p_state: null`, and 078's RPC merges with + // `CASE WHEN p_state IS NULL THEN sync_state ELSE sync_state || p_state END` — so a null + // preserved the PREVIOUS run's map. When a run then died before its terminal write (serverless + // timeout), the row was left `sync_status='syncing'` over a `sync_state` that still read + // `{"stripe":{"status":"synced"}}` from weeks earlier. Measured on cappy's `cappy_plus_guardian` + // 2026-10-06: stranded `syncing` for 14+ minutes while its state payload claimed every provider + // had succeeded — a dead run that is indistinguishable from a complete one, which is worse than + // either a failure or an empty state. + // + // Only the keys in THIS run are reset; providers not being synced keep their recorded state, + // which is the merge semantics the RPC exists for (a single-provider retry must not wipe the rest). + const inFlight = Object.fromEntries( + keys.map((k) => [k, { status: "syncing", reason: "sync in progress" } as ProviderSyncEntry]), + ) + await supabase.rpc("tenant_products_set_sync_state", { + p_id: productId, + p_status: "syncing", + p_state: inFlight, + }) const emit = opts.runId ? (phase: string, provider: string | null, status: string | null, message: string) => supabase @@ -822,23 +873,53 @@ export async function runProductSync( // Emit a start + result event per provider so the dashboard renders a live log. const providers: Record = {} - await Promise.all( - keys.map(async (k) => { - const label = PROVIDER_LABEL[k] ?? k - if (emit) await emit("start", k, null, `Syncing ${product} → ${label}…`) - const entry = classifyProvider(await runners[k]()) - providers[k] = entry - if (emit) { - const phase = entry.status === "failed" ? "failed" : entry.status === "skipped" ? "skipped" : "ok" - await emit(phase, k, entry.status, providerSyncMessage(label, product, entry)) + try { + await Promise.all( + keys.map(async (k) => { + const label = PROVIDER_LABEL[k] ?? k + if (emit) await emit("start", k, null, `Syncing ${product} → ${label}…`) + const entry = classifyProvider(await runners[k]()) + providers[k] = entry + if (emit) { + const phase = entry.status === "failed" ? "failed" : entry.status === "skipped" ? "skipped" : "ok" + await emit(phase, k, entry.status, providerSyncMessage(label, product, entry)) + } + }), + ) + } catch (e: any) { + // A throw out of the fan-out used to skip the terminal write entirely, leaving the row + // `syncing` forever. Record the failure, then rethrow so the caller still sees the error. + const reason = `sync aborted: ${e?.message ?? String(e)}` + for (const k of keys) { + if (!providers[k] || providers[k].status === "syncing") { + providers[k] = { status: "failed", error: reason, reason } } - }), - ) + } + await supabase.rpc("tenant_products_set_sync_state", { + p_id: productId, + p_status: "failed", + p_state: providers, + }) + if (emit) await emit("run_done", null, "failed", `${product}: ${reason}`) + throw e + } + + // READ BACK before claiming `synced`. A provider helper reporting ok only proves the WRITE was + // accepted by the client we hold; it does not prove the object is retrievable afterwards. + // Measured on cappy + reels-downloader 2026-10-06: all 7 products carried + // `sync_state.stripe.status='synced'` and `sync_status='synced'` while every `stripe_product_id` + // was unreadable with the tenant's connected live key. The flag recorded what we believed. + // CHAIN.3/A5 of /idea-paycraft already warned about exactly this; the readback is what makes the + // flag mean something. + if (providers.stripe?.status === "synced") { + const verdict = await verifyStripeProduct(supabase, opts) + if (verdict) providers.stripe = verdict + } const values = Object.values(providers) const status: ProductSyncSummary["status"] = values.some((v) => v.status === "failed") ? "failed" - : values.some((v) => v.status === "draft") + : values.some((v) => v.status === "draft" || v.status === "syncing") ? "partial" : "synced" @@ -852,3 +933,43 @@ export async function runProductSync( return { status, providers } } + +/** + * Post-write readback for Stripe: resolve the product id we just wrote and retrieve it with the + * tenant's connected client. Returns a replacement entry when the readback DISPROVES success, or + * `null` to leave the reported `synced` standing. + * + * The probe mirrors `detectProductsMissingAtProvider` in `./drift-detectors` deliberately — same + * `products.retrieve` + inactive check, so the sync path and the drift path cannot disagree about + * what "present at Stripe" means. Unlike that one it keeps the error MESSAGE: a bare + * `catch {}` collapses "deleted", "wrong account" and "revoked key" into one unreadable, which is + * what made the cappy/reels failure undiagnosable from the drift output alone. + */ +async function verifyStripeProduct( + supabase: ReturnType, + opts: SyncOptions, +): Promise { + try { + const { data } = await supabase + .from("tenant_products") + .select("stripe_product_id") + .eq("id", opts.productId) + .maybeSingle() + const id = data?.stripe_product_id ?? opts.existingStripeProductId + if (!id) { + const reason = "stripe reported synced but no stripe_product_id was recorded" + return { status: "failed", error: reason, reason } + } + const stripe = await getConnectedStripeClient(opts.tenantId, "live") + if (!stripe) return null // no live client resolvable here — the live-readiness gate owns that + const remote = await stripe.products.retrieve(id) + if (!remote || remote.active === false) { + const reason = `stripe_product_id=${id} is inactive at Stripe immediately after sync` + return { status: "failed", error: reason, reason } + } + return null + } catch (e: any) { + const reason = `stripe readback failed after sync: ${e?.message ?? String(e)}` + return { status: "failed", error: reason, reason } + } +} diff --git a/dashboard/lib/sync-drain.ts b/dashboard/lib/sync-drain.ts index 578f5adb..1223b4eb 100644 --- a/dashboard/lib/sync-drain.ts +++ b/dashboard/lib/sync-drain.ts @@ -142,6 +142,8 @@ export async function runSyncDrain( runId, existingStripeProductId: loaded.product.stripe_product_id ?? undefined, existingPrices: loaded.product.stripe_price_id_by_currency ?? undefined, + existingStripeProductIdTest: loaded.product.stripe_product_id_test ?? undefined, + existingPricesTest: loaded.product.stripe_price_id_by_currency_test ?? undefined, existingRazorpayPlanIds: loaded.product.razorpay_plan_id_by_currency ?? undefined, existingRazorpayPlanIdsTest: loaded.product.razorpay_plan_id_by_currency_test ?? undefined, existingPlayProductId: loaded.product.play_product_id ?? undefined, @@ -173,6 +175,8 @@ export async function runSyncDrain( onlyProvider: provider, existingStripeProductId: loaded.product.stripe_product_id ?? undefined, existingPrices: loaded.product.stripe_price_id_by_currency ?? undefined, + existingStripeProductIdTest: loaded.product.stripe_product_id_test ?? undefined, + existingPricesTest: loaded.product.stripe_price_id_by_currency_test ?? undefined, existingRazorpayPlanIds: loaded.product.razorpay_plan_id_by_currency ?? undefined, existingRazorpayPlanIdsTest: loaded.product.razorpay_plan_id_by_currency_test ?? undefined, existingPlayProductId: loaded.product.play_product_id ?? undefined, diff --git a/idea-layer/state/AGENT_AWARENESS.jsonl b/idea-layer/state/AGENT_AWARENESS.jsonl deleted file mode 100644 index 8ffeb7c3..00000000 --- a/idea-layer/state/AGENT_AWARENESS.jsonl +++ /dev/null @@ -1,22 +0,0 @@ -{"ts":"2026-07-21T17:49:23Z","command":"systematic-debugging","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-07-21T18:09:06Z","command":"systematic-debugging","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-08-01T14:03:46Z","command":"goal-analysis-project","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-03T12:28:08Z","command":"artifact-design","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-04T09:09:25Z","command":"superpowers:brainstorming","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-04T09:54:41Z","command":"goal-research","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-04T20:26:50Z","command":"goal-research","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-05T09:02:48Z","command":"context-start","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-06T08:48:47Z","command":"goal-research","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-06T10:41:17Z","command":"goal-analysis-project","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-06T11:44:08Z","command":"lib-integrate","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-12T13:44:32Z","command":"lib-integrate","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-13T06:25:41Z","command":"web-debug","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-15T14:33:50Z","command":"context-start","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-15T20:25:10Z","command":"kmp-project-template-retrain","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-16T07:17:45Z","command":"idea-build-kmp","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-16T13:16:00Z","command":"idea-build-kmp","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-17T15:48:55Z","command":"context-start","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-19T06:46:36Z","command":"lib-fix","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-21T11:45:09Z","command":"idea-build-kmp","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-23T14:22:31Z","command":"artifact-design","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} -{"ts":"2026-09-24T07:08:26Z","command":"idea-stitch-account-assign","actor":"claude","dispatched_by":"standalone","verified_by_parent":false,"verdict":null} diff --git a/idea-layer/state/BEHAVIOR_VERDICTS.yaml b/idea-layer/state/BEHAVIOR_VERDICTS.yaml deleted file mode 100644 index bbd12c1e..00000000 --- a/idea-layer/state/BEHAVIOR_VERDICTS.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# PayCraft v2.1 — E6 onboarding + device-verify ship gate (sub-plan 06, D14 / AC8). -# Device-truth verdicts per RULE-IMPL-BEHAVIOR-EXECUTED-001. Authored by Claude (Write tool, -# RULE-CI-001 compliant) from the ACTUAL on-device maestro runs; NOT machine-mutated. -# -# verdict vocabulary: maestro-executed (device-verified, non-pending) | pending-device-verify (FAIL/non-pass) -schema_version: "1.0" -gate: G-6 -generated_at: "2026-07-22T02:00:00Z" # re-verified with paywall coverage on koin-4.1.0 build -# PAYWALL COVERAGE (2026-07-22): all 8 flows now EXERCISE the paywall — tap "Open Paywall" -> -# PayCraftSheet -> koinViewModel() -> assert "Your Premium" + "Premium Active" -# render (Premium is seeded, so the paywall VM resolves the Premium state). This closes the blind spot -# where the koin 4.0.2 IrLinkageError (paywall ViewModel resolve) slipped the gate; fixed by koin 4.1.0. -device: - android: - serial: emulator-5554 # clean AOSP emulator (sdk_gphone16k_arm64) — reliable device-truth - model: sdk_gphone16k_arm64 - app_id: com.mobilebytelabs.paycraft.sample - built_apk_md5: 229a05d2c00b974b854b9c61f9295f3f # koin-4.1.0 debug APK - device_apk_md5: 229a05d2c00b974b854b9c61f9295f3f # emulator-installed APK — md5-match OK (RULE-IMPL-BEHAVIOR-EXECUTED-001) - md5_match: true - maestro_version: "2.6.1" - # NOTE: OnePlus/ColorOS phone Q4CIZHA6Y5GMJV89 (CPH2423) is environmentally FLAKY for the play lane - # (failed at scroll / provider-text-assert / launchApp across runs; NO paycraft.sample crash in - # logcat). Same flows pass cleanly on the emulator → flakiness is ColorOS app-management, not a - # defect and not the paywall edit. Android device-truth recorded against the emulator. - ios: - booted_simulator: "iPhone 17 Pro (AC5CF578-12A0-4E12-9DF7-8540B0551538), iOS 26.5" - app_id: com.mobilebytelabs.paycraft.sample - app_build: "sample-app/iosApp (xcodegen SwiftUI wrapper embedding SampleApp.framework) — koin-4.1.0, already installed" - device_app_md5: 1dce2038d83acd4a403334050c7a9e6f # installed-on-sim binary (koin-4.1.0 fix); flows-only change, no iOS rebuild this run - md5_match: true # verified against the installed koin-4.1.0 binary (task-provided; no rebuild needed for a flows-only edit) - xcode_version: "26.5" - maestro_version: "2.6.1" - -verdicts: - # ── Android: ALL 4 providers device-verified GREEN on emulator-5554 (0 failures), WITH paywall coverage ── - - { provider: apple_storekit, platform: android, flow: maestro/paycraft_storekit_android_flow.yaml, verdict: maestro-executed, evidence: "all steps COMPLETED; Premium+apple_storekit+will_renew, manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', no crash), cache-first restore relaunch; apk md5-match 229a05d2..." } - - { provider: google_play, platform: android, flow: maestro/paycraft_play_android_flow.yaml, verdict: maestro-executed, evidence: "all steps COMPLETED; Premium+google_play+will_renew, manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', no crash), cache-first restore relaunch; apk md5-match" } - - { provider: stripe, platform: android, flow: maestro/paycraft_stripe_android_flow.yaml, verdict: maestro-executed, evidence: "all steps COMPLETED; Premium+stripe+will_renew, manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', no crash), cache-first restore relaunch; apk md5-match" } - - { provider: razorpay, platform: android, flow: maestro/paycraft_razorpay_android_flow.yaml, verdict: maestro-executed, evidence: "all steps COMPLETED; Premium+razorpay+will_renew, manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', no crash), cache-first restore relaunch; apk md5-match" } - - # ── iOS: ALL 4 providers device-verified GREEN on iPhone 17 Pro sim (iOS 26.5), 0 failures, WITH paywall coverage ── - - { provider: apple_storekit, platform: ios, flow: maestro/paycraft_storekit_ios_flow.yaml, verdict: maestro-executed, evidence: "exit=0, all steps COMPLETED; Premium+apple_storekit+will_renew, banner manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', koinViewModel resolves w/o IrLinkageError), cache-first offline restore relaunch; koin-4.1.0 binary md5 1dce2038..." } - - { provider: google_play, platform: ios, flow: maestro/paycraft_play_ios_flow.yaml, verdict: maestro-executed, evidence: "exit=0, all steps COMPLETED; Premium+google_play+will_renew, banner manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', no crash), cache-first offline restore relaunch; koin-4.1.0 binary md5-match" } - - { provider: stripe, platform: ios, flow: maestro/paycraft_stripe_ios_flow.yaml, verdict: maestro-executed, evidence: "exit=0, all steps COMPLETED; Premium+stripe+will_renew, banner manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', no crash) + screenshot-confirmed, cache-first offline restore relaunch; koin-4.1.0 binary md5-match" } - - { provider: razorpay, platform: ios, flow: maestro/paycraft_razorpay_ios_flow.yaml, verdict: maestro-executed, evidence: "exit=0, all steps COMPLETED; Premium+razorpay+will_renew, banner manage CTA, PAYWALL render (Open Paywall -> 'Your Premium'+'Premium Active', no crash), cache-first offline restore relaunch; koin-4.1.0 binary md5-match" } - -summary: - android: 4/4 maestro-executed (device-verified on emulator-5554, koin-4.1.0, WITH paywall coverage) - ios: 4/4 maestro-executed (device-verified on iPhone 17 Pro sim iOS 26.5, koin-4.1.0, WITH paywall coverage) - paywall_coverage: "8/8 flows now open PayCraftSheet and assert the paywall koinViewModel resolves + renders (Your Premium + Premium Active) without the IrLinkageError crash" - g6_status: green # 4 providers x BOTH platforms device-verified (8/8 flows maestro-executed, 0 pending), paywall-covered diff --git a/idea-layer/state/PAYCRAFT_PHASE4_SECRETS_VERDICT.yaml b/idea-layer/state/PAYCRAFT_PHASE4_SECRETS_VERDICT.yaml deleted file mode 100644 index 65a27a06..00000000 --- a/idea-layer/state/PAYCRAFT_PHASE4_SECRETS_VERDICT.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# PayCraft Phase 4 — publish-secret verdict. -# METADATA ONLY. No values, no head/cat/base64 (RULE-SECRETS-NO-VALUE-EGRESS-001). -# Presence = the vault resolver succeeded AND produced a non-empty file. Nothing is read. -generated_at: "2026-09-05T09:52:52Z" -registry_group: maven-central -scopes: - framework-maven-central-username: present - framework-maven-central-password: present - framework-gpg-signing-key: present - framework-gpg-signing-password: present diff --git a/idea-layer/state/PAYCRAFT_PHASE4_VERDICTS.yaml b/idea-layer/state/PAYCRAFT_PHASE4_VERDICTS.yaml deleted file mode 100644 index 10cf30ec..00000000 --- a/idea-layer/state/PAYCRAFT_PHASE4_VERDICTS.yaml +++ /dev/null @@ -1,370 +0,0 @@ -# PayCraft Phase 4 — per-consumer, per-platform release verdict grid (D10). -# -# This is the artefact D10 reads to decide whether the epic is done. Its whole purpose is to make -# "done" un-fakeable at the granularity where the previous epic failed: six green gates and 47 tasks -# that never reached a device (RESEARCH R8). -# -# THE RULE THAT MATTERS: a platform row may only be `green` if it carries its OWN capture_path. -# An iOS row can never inherit an Android capture — different binary, different store lane, different -# storefront API — and that inheritance is precisely how a paywall ships broken on one platform while -# a dashboard shows all-green. -# -# verdict vocabulary: -# green bumped AND device-verified with a capture of THIS platform -# bumped-not-verified pin landed, no device capture yet (a publish is NOT rolled back by this) -# pending-device-verify no device available; explicitly NOT a pass -# out-of-scope deliberately excluded, requires a stated reason - -generated_at: "2026-09-05T09:53:00Z" - -publish: - # PUBLISHED. This block previously read `published_version: null / - # status: awaiting-operator-authorisation / sdk_version_on_disk: "2.3.2"`, written 2026-09-05. - # That is stale: 2.4.0 reached Maven Central on 2026-09-17 (pom Last-Modified), which is AFTER the - # SDK absorbed its StoreKit shim on 2026-09-16 — so the published artefact carries the new lane. - # Verified 2026-09-21 by direct fetch, not by inference. - published_version: "2.4.0" - sdk_version_on_disk: "2.4.0" - status: published - route: "/lib-publish release" - verified_at: "2026-09-21T17:05:00Z" - evidence: - pom: "https://repo1.maven.org/maven2/io/github/mobilebytelabs/cmp-paycraft/2.4.0/cmp-paycraft-2.4.0.pom → HTTP 200, Last-Modified Thu, 17 Sep 2026 06:31:12 GMT" - storekit_lane_in_artefact: "cmp-paycraft-iossimulatorarm64-2.4.0-cinterop-paycraftStoreKit.klib present on Central" - dr4_holds_in_published_pom: "supabase 3.8.0 · store5 5.1.0-alpha10 · kermit ABSENT — every DR-4 remedy is in the published dependency set" - -# SCOPE (operator decision, 2026-09-21): epic 1 ships the CAPPY consumer end to end. The -# reels-downloader and steady rollout is split to a follow-up epic — see GOAL.md `## Out of scope`. -# This narrowing is recorded here rather than achieved by shrinking the gate: the AC-13 evaluator -# still defaults to all three consumers, so a later full run reports the two that remain. -consumers: - - consumer: reels-downloader - pinned_version: "2.3.1" - platform: android - bumped: false - verified: false - verdict: out-of-scope - reason: "Split to the follow-up consumer-rollout epic (operator decision 2026-09-21). Still on 2.3.1, which predates the SDK-carried StoreKit lane; its six-member per-app shim is CORRECT for the version it pins and must not be touched ahead of its bump." - capture_path: null - - consumer: reels-downloader - pinned_version: "2.3.1" - platform: ios - bumped: false - verified: false - verdict: out-of-scope - reason: "Split to the follow-up consumer-rollout epic (operator decision 2026-09-21)." - capture_path: null - - - consumer: steady - pinned_version: "2.3.1" - platform: android - bumped: false - verified: false - verdict: out-of-scope - reason: "Split to the follow-up consumer-rollout epic (operator decision 2026-09-21). Same 2.3.1/shim pairing as reels-downloader." - capture_path: null - - consumer: steady - pinned_version: "2.3.1" - platform: ios - bumped: false - verified: false - verdict: out-of-scope - reason: "Split to the follow-up consumer-rollout epic (operator decision 2026-09-21). AC-35's steady offline-onboarding proof travels with it." - capture_path: null - - # ── cappy — epic 1's shipped consumer, verified end to end 2026-09-21 ────────────────────────── - - consumer: cappy - pinned_version: "2.4.0" - platform: android - bumped: true - verified: true - verdict: green - reason: "Built against PUBLISHED 2.4.0 (lib-integrate paycraft.local=false, artefact resolved from Maven Central into the Gradle module cache — not the composite). Installed, md5 parity verified, force-stopped, cold-launched: renders the real Home surface, ZERO FATAL EXCEPTION and zero NoSuchMethodError — the DR-4 crash class is clear at the published dependency set." - device: "emulator-5554 (Pixel_10_Pro AVD)" - apk_md5: "0ffa59447da7377477e02ffd5319b4b6 (device == built, verified both sides)" - capture_path: "idea-layer/testers/captures/paycraft-epic1-cappy/t9-cappy-android-cold-launch.png" - note: "PayCraft config fetch failed with `all resilience layers exhausted — OFFLINE` because this machine's cappy local.properties sets CAPPY_PAYCRAFT_LOCAL_URL=http://127.0.0.1:54321 and no local Supabase is running. That is a developer opt-in, not an app defect — and the graceful OFFLINE terminal is the Phase-3a resilience chain (AC-20..24) observed live rather than asserted." - - consumer: cappy - pinned_version: "2.4.0" - platform: ios - bumped: true - verified: true - verdict: green - reason: "xcodebuild -scheme prodDebug against the published 2.4.0 → BUILD SUCCEEDED with NO per-app shim, proving the SDK-carried cinterop lane links. Installed + launched on simulator; StoreKit resolved a real storefront." - device: "iPhone 17 simulator, iOS 26.5 (1F13FC9E-528E-4543-87BA-637EBE8AB12D)" - capture_path: "idea-layer/testers/captures/paycraft-epic1-cappy/t9-cappy-ios-cold-launch.png" - storefront_capture: ".claude-runtime/state/ios-storefront-captures/cappy.json" - storefront_country: "USA" - note: | - AC-13 proven by three independent facts rather than one: - 1. StoreKit itself logged `Storefront updated ... countryCode: "USA"` at launch. - 2. The shipped Cappy.debug.dylib carries PayCraftStoreKitShim x9 (incl. the mangled ObjC - class _TtC20PayCraftStoreKitShim13PCTransaction) and storefrontCountry x3. - 3. PayCraftStoreKit2 (the superseded per-app shim) appears ZERO times, and cappy ships no - StoreKit source of its own — so the SDK-internal lane is the only path that could have - driven the StoreKit/TransactionQuery + TransactionUpdateStart seen at launch. - countryCode is alpha-3 as StoreKit returns it; the server normalizes in _shared/country-code.ts. - -# AC-35 — the single capture this epic exists for: steady gates onboarding behind the paywall with -# a no-op dismiss unless already Premium, so before Phase 3a an offline user at first launch was -# permanently stuck inside a shipped app. This asserts the four-layer chain renders a purchasable or -# dismissible surface with the network off. -ac35_steady_offline_onboarding: - # PREMISE NOT REPRODUCIBLE — not a pass, and not a failure of the resilience chain. - # - # AC-35 assumed steady gates onboarding behind the paywall, so an offline first-run user is - # stranded. That gate is NOT live in the current build: RootNavScreen.kt registers - # `splashDestination()` then `authenticatedGraph(...)` with `onboardingDestination()` COMMENTED - # OUT, and RootNavState.ShowOnboarding maps to an empty route. Verified on device - # (CPH2423 / Android 15): uninstall + reinstall + radios off → cold launch lands on Home, fully - # usable. The RESEARCH finding (F23) that motivated this AC is stale. - # - # The paywall screen still exists and still carries the no-op dismiss, but nothing routes a - # first-run user to it — its only in-app trigger is account deletion. - verdict: premise-not-reproducible - reason: "steady's onboarding gate is disabled at the root nav (onboardingDestination commented out); offline cold start lands on Home, not a paywall. Verified on device 2026-09-05." - capture_path: "idea-layer/testers/captures/paycraft-phase4/ac35-steady-offline-cold-01.png" - device: "CPH2423 (Android 15), apk md5 a49c4802ddbb6b90a46ebc355c988ea1" - offline_proof: "svc wifi disable + svc data disable; ping 8.8.8.8 → Network is unreachable" - rescoped_to: "reels-downloader Settings → Subscription (a reachable PayCraftPaywall host)" - -# Findings the device run itself surfaced. No gate in this epic would have caught any of them, -# because all three are properties of the running app and its environment rather than the source. -device_run_findings: - - id: DR-1 - what: "steady's profile tab renders completely blank offline — no content, no error, no retry" - capture_path: "idea-layer/testers/captures/paycraft-phase4/ac35-steady-offline-profile-blank.png" - - id: DR-2 - what: "steady's paywall has NO reachable entry point — onboardingDestination is registered only in the authenticated graph and its sole in-app trigger is account deletion" - - id: DR-3 - what: "airplane_mode_on=1 does NOT disable radios on this OEM — the device still reached 8.8.8.8 in 15ms with the flag set. Any offline test that trusts that setting is measuring nothing." - - - id: DR-4-RESOLVED - severity: RESOLVED-ON-DEVICE - resolved_at: "2026-09-05T20:15:00Z" - device: "CPH2423 (Android 15), serial Q4CIZHA6Y5GMJV89, apk md5 2ba5dd917a58f97991bed2f050085f9d (device == built, verified)" - what: | - reels-downloader now LAUNCHES, RUNS and RENDERS A PAYWALL against a PayCraft built from the - held dependency set. Zero FATAL EXCEPTION across the run. The paywall capture shows all four - products with their tier pricing and savings badges — i.e. the exact composition path that - died minutes earlier (PaywallTemplate.render -> ProductList) is healthy. - fixes_that_got_here: - - "supabase held at 3.1.4 (operator decision) — below all three consumers, so each resolves up to its own pin" - - "store5 held at 5.1.0-alpha10 — removes the transitive kermit 2.1.0 force; reels now resolves kermit 2.0.8" - - "kermit removed from PayCraft entirely; PayCraftLogger is the sole logger" - - "ProductList.kt:396 regex closing brace escaped — the ICU/JVM divergence below" - captures: - - "idea-layer/testers/captures/paycraft-phase4/dr4-01-home-after-fixes.png" - - "idea-layer/testers/captures/paycraft-phase4/dr4-02-paywall-rendered.png" - sequencing_note: | - Each fix only revealed the next crash — the app died in AppOpenAdManager before it could ever - compose a paywall, so the regex defect was invisible until the dependency fixes landed. This is - why "the SDK compiles, 385 tests pass, every gate is green" stayed true while the product was - unlaunchable. Crash order was: createLogger$default -> d$default -> ExceptionInInitializerError. - - - id: DR-5 - severity: OPEN — in epic scope (goal item (g), country-correct price resolution) - what: | - The price resolver and the rendered prices disagree, and the resolver disagrees with itself. - Reproduced twice on device (fresh force-stop + cold launch each time): - - device locale = GB - config request = country=GB - resolved active region -> country=IN currency=GBP - rendered paywall -> $9.99 / $28.47 / $53.95 / $99.50 (dollar glyph) - - Two separate anomalies in one trace: - (a) the active region flips GB -> IN even though both the device locale and the outgoing - request say GB; - (b) currency resolves to GBP but the UI renders a '$' glyph. - Either could be a display-formatting bug or a genuine resolver fault; this entry deliberately - does NOT assert which, because the evidence so far cannot distinguish them. - why_it_matters: | - This is revenue-facing and it is the ORIGINAL goal item (g) — "resolve prices according to - country, auditing every detection signal". A paywall that renders a dollar glyph against a GBP - resolution, in a session whose locale is GB and whose region resolved to IN, is wrong under - every reading of that requirement. - found_by: "device capture during DR-4 verification — no gate in this epic examines rendered currency against resolved currency" - evidence: "idea-layer/testers/captures/paycraft-phase4/dr4-02-paywall-rendered.png + logcat PayCraft:D" - - # ── ROOT CAUSE (2026-09-05) ──────────────────────────────────────────────────────────────── - root_cause: | - The price resolver had ZERO production call sites. - - displayPrice() — which folds native store price > cloud per-locale resolvedPrice > tenant base - price — was called only from tests. All EIGHT render sites (PayCraftPaywallComposable x2, - ProductList x2, BuiltInPaywall x2, PlanCard x2) called basePrice.format() directly, so every - template rendered the tenant BASE price in `base_currency` (default "USD") regardless of what - CurrencyResolver decided. The resolution machinery was complete, documented, unit-tested, and - wired to nothing. - - That fully explains the contradictory trace: country resolved IN, currency resolved GBP, and - the paywall rendered "$" — the UI consulted neither. - severity_correction: | - Higher than the "display bug" this was first logged as. The provider checkout link is selected - from the ACTIVE currency (checkoutCurrency(active, available)) while the displayed price came - from base currency — so a shopper can be SHOWN one currency and BILLED another. That is the - exact divergence CurrencyResolver's own docstring says it exists to prevent, reintroduced one - layer up at render time. - my_earlier_error: | - The original DR-5 entry flagged `country=IN` as anomaly (a). It is NOT a defect — the resolver - is storefront-first BY DESIGN and its docstring describes this precise case: an India Play - storefront with an en-GB device locale must resolve IN so price and providers bill in the - buyer's real billing region. The test device matches that shape exactly. Only the currency - half of DR-5 was ever real. - fix: | - Added Product.sessionDisplayPrice() / sessionDisplayPriceFormatted() in ProductPricing.kt, - reading the live session through the already-internal PayCraft.suiteConfig + - nativePriceForSku(), and rewired all eight render sites. The render sites are pure functions - with no SuiteConfig in scope — which is WHY the wiring was never done — so reading the session - avoids threading config + native price through every template signature. - verified_statically: - - "compileKotlinJvm BUILD SUCCESSFUL" - - "385 jvm tests pass" - - "14/14 Roborazzi goldens unchanged (expected — fixture configs carry no resolvedPrice, so the fallback path renders identically)" - - "G-PRICE-RENDER-RESOLVED gate + red/green canary pass; registered in COHERENCE_CATALOG and green through the aggregator" - device_verification: verified-on-device - device_verification_result: | - RESOLVED 2026-09-06 on CPH2423 / Android 15 (serial Q4CIZHA6Y5GMJV89), device-APK md5 - 19913f6c32c2740315594cfab868e8f0 == built. Paywall now renders ONE currency end to end: - - Pro Monthly £7.92 / month - Pro Quarterly £22.77 / quarter £7.59 / mo billed quarterly SAVE 4% - Pro Semi-annual £43.56 / 6 months £7.26 / mo billed every 6 mo SAVE 8% - Pro Annual £79.20 / year £6.60 / mo billed annually SAVE 16% - - 7 pound-denominated lines, 0 dollar lines, 0 fatals this run. - Capture: idea-layer/testers/captures/paycraft-phase4/dr5-01-paywall-resolved-currency.png - second_defect_found_by_the_first_capture: | - The FIRST fix was incomplete and only the device revealed it. After rewiring the eight - `basePrice.format()` sites the paywall rendered "£79.20 / year" with "$8.29 / mo billed - annually" DIRECTLY BENEATH IT — two currencies in one card. Three further sites in - ProductList.kt computed from basePrice FIELDS rather than .format(): - - computePerMonthAnchor (basePrice.amountMinor + basePrice.currency) - - computeSavingsPercent (basePrice.amountMinor) - - the monthlyBaseline derivation in buildProductRows (basePrice.amountMinor) - G-PRICE-RENDER-RESOLVED's first version matched only `basePrice.format()` and passed clean - while all three survived. A gate scoped to ONE access shape proves only that one access shape - is absent; PRR-1 now matches .format() / .amountMinor / .currency, and the canary gained a RED - fixture for the field-read shape specifically. - savings_percentages_were_also_wrong: | - Not merely mislabelled — miscalculated. The badges read 5% / 10% / 17% because the per-month - figure came from a GBP plan price while the baseline came from a USD monthly price: a ratio - taken across two currencies. Correct values against the £7.92 baseline are 4% / 8% / 16%, - each verified by hand (22.77/3=7.59 → 4.2%; 43.56/6=7.26 → 8.3%; 79.20/12=6.60 → 16.7%). - regression_locks: - - "cmp-paycraft/src/commonTest/.../model/SessionDisplayPriceTest.kt — 3 tests, proves the RESOLUTION (resolved-wins, base-fallback, no-config-fallback)" - - "G-PRICE-RENDER-RESOLVED + tests/fixtures/price-render-resolved-canary — proves the WIRING (that the UI calls the resolver at all)" - - "the device capture above — proves the RENDERED result" - why_three_locks: | - No single one is sufficient. A unit test of displayPrice() cannot detect that nobody calls it - — its own tests passed for the entire period the paywall ignored it. A grep gate cannot prove - the arithmetic is right. And the 14 Roborazzi goldens stayed `unchanged` through BOTH rounds - of this fix, because every screenshot fixture builds a SuiteConfig with no resolvedPrice and - therefore exercises only the fallback path — which is exactly how 14 passing goldens coexisted - with a paywall showing the wrong currency. - suite_after_fix: "388 jvm tests pass (was 385 + 3 new), 14/14 goldens unchanged" - device_verification_note: | - NOT yet confirmed on device. The falsifiable prediction is that the paywall now renders "£" - rather than "$": _activeCurrency resolved GBP from `resolvedPrice?.currency ?: baseCurrency`, - and since the UI rendered "$", baseCurrency must be USD — so the GBP necessarily came from a - real cloud resolvedPrice, which the fix now routes to the display. - - The device (CPH2423, serial Q4CIZHA6Y5GMJV89) DISCONNECTED before this could be captured — - `adb devices` empty after a kill-server/start-server cycle, and installProdDebug failed with - "No connected devices!". The built APK is retained at - cmp-android/build/outputs/apk/prod/debug/cmp-android-prod-debug.apk so the check is a - reconnect + install away. reels-downloader's 2.4.0-SNAPSHOT test pin was reverted to 2.3.1 so - the harness edit cannot leak into a commit; re-apply it to resume. - Per RULE-IDEA-AGENT-003 device-truth this stays non-pass until a capture exists. - - - id: DR-4 - severity: RELEASE-BLOCKING - what: | - Publishing PayCraft at its current dependency set CRASHES consumers on launch, before any UI. - - Published 2.3.1 declares Supabase 3.1.4 — BELOW every consumer's pin — so Gradle resolves the - shared modules DOWN to the consumer's version and everything is self-consistent. PayCraft's - source has since moved ABOVE the consumers (Supabase 3.1.4 → 3.8.0, Kermit 2.0.8 → 2.1.0), - which INVERTS the resolution: transitively-shared modules jump forward while the consumer's - directly-pinned artifacts stay behind, and the mismatched pair fails at class-init. - - Reproduced on device (CPH2423 / Android 15) against a mavenLocal 2.4.0-SNAPSHOT, three - independent failures, each surviving the fix for the previous one: - 1. NoSuchMethodError SupabaseClient$Companion.createLogger$default (Supabase 3.2.6 vs 3.8.0) - 2. NoSuchMethodError kermit Logger$Companion.d$default (Kermit 2.0.8 vs 2.1.0) - 3. NoSuchMethodError SelectRequestBuilder.(PropertyConversionMethod) - - The third one is the important one: it is not a pin skew but an API BREAK. reels-downloader's - OWN SOURCE calls Postgrest APIs whose shape changed in 3.8.0. So this is not "bump a few - versions" — each consumer needs a Supabase source migration. - implication_for_ac33: | - /lib-install-bump-workflow bumps the paycraft pin ONLY. That is not a bump, it is a launch - crash. Every consumer PR must carry a coordinated dependency upgrade AND a source migration, - and must be device-verified BEFORE merge rather than after. - why_no_gate_caught_it: | - Nothing in this epic could have. The SDK compiles, 385 tests pass, all goldens verify, every - gate is green — none of which exercises dependency RESOLUTION inside a consumer application. - It requires a real install on a real device, which is the entire argument for D10's - definition of done. - options: - - "Hold PayCraft at Supabase 3.1.x for this release so consumers resolve down as they do today (smallest blast radius; defers the upgrade)" - - "Publish 3.8.0 and migrate all three consumers' Supabase source in the same release train (correct long-term; much larger scope than this epic)" - evidence: "device logcat, three crash traces (CPH2423 / Android 15)" - - # CORRECTION 2026-09-05 — an earlier revision of this entry asserted two things that a direct - # read of the consumer trees disproved. Both are recorded rather than silently overwritten, - # because the wrong version numbers are what made the original analysis look tidier than it was. - # - # WRONG: "reels-downloader pins reverted to 2.3.1 / 3.2.6 / 2.0.8 and its tree is clean" - # TRUE: reels-downloader still carries an UNCOMMITTED working-tree edit - # paycraft 2.3.1 -> 2.4.0-SNAPSHOT (the local mavenLocal test pin). It was never - # reverted. Nothing else in that file is modified. - # - # WRONG: "Supabase 3.1.4 is BELOW every consumer's pin" stated against a single assumed - # consumer set of 3.2.6 / 2.0.8. - # TRUE: the three consumers are NOT aligned with each other (verified pins below). - # NOTE ON THE kermit COLUMN BELOW: these are each CONSUMER APP's OWN kermit pin, declared in its - # own libs.versions.toml for its own logging (89 files in reels, 16 in steady, 15 in cappy import - # co.touchlab directly). They are NOT PayCraft dependencies and are NOT retained by PayCraft. - # PayCraft's kermit was REMOVED outright this session — it carried both kermit and its own - # PayCraftLogger, and the duplication is what was eliminated; PayCraftLogger is the survivor. - # The versions are recorded here only as the evidence for crash #2 — see the exact mechanism below. - # - # CRASH #2, TRUE ROOT CAUSE (device-proven 2026-09-05, CPH2423, apk md5 0a215e9b…): - # Removing PayCraft's DIRECT kermit dependency did NOT fix the crash. Re-tested on device with a - # de-kermited 2.4.0-SNAPSHOT and reels still died at launch with the identical - # NoSuchMethodError Logger$Companion.d$default. The real path is TRANSITIVE: - # - # co.touchlab:kermit:2.1.0 - # <- org.mobilenativefoundation.store:store5-android:5.1.0-alpha11 - # <- io.github.mobilebytelabs:cmp-paycraft-android - # - # Store5 alpha11 raised its kermit requirement 2.0.5 -> 2.1.0. dependencyInsight shows EVERY other - # requester in reels' graph asking for 2.0.x (2.0.8 / 2.0.5 / 2.0.3); PayCraft->Store5 was the sole - # requester of 2.1.0, and Gradle's highest-wins conflict resolution did the rest. So PayCraft was - # indeed the cause, just never through a dependency line anyone could see by reading its build file. - # - # Two wrong readings were recorded before this and are kept for the record: - # (a) "PayCraft directly drags kermit 2.1.0" — the direction was right, the path was wrong. - # (b) "crash #2 was never PayCraft's to cause" — an over-correction made because the crash STACK - # is entirely in reels' own AppOpenAdManager. The crash site is the victim, not the cause; - # a NoSuchMethodError always surfaces in whoever calls the method, never in whoever moved it. - # - # FIX: hold Store5 at 5.1.0-alpha10 (requires kermit 2.0.5). Nothing is forced upward and no - # consumer changes at all. Same shape as the supabase 3.1.x hold. - consumer_own_pins_verified_2026_09_05: - reels-downloader: {paycraft: "2.3.1 (working edit -> 2.4.0-SNAPSHOT)", supabase: "3.2.6", kermit: "2.0.8"} - steady: {paycraft: "2.3.1", supabase: "3.6.0", kermit: "2.1.0"} - cappy: {paycraft: "2.3.1", supabase: "3.6.0", kermit: "2.1.0"} - what_the_real_pins_change: | - The mechanism in DR-4 stands, but the blast radius was mis-stated. PayCraft's source Supabase - 3.8.0 is above ALL THREE consumers (3.2.6, 3.6.0, 3.6.0), so the inversion applied everywhere, - not just to reels. Kermit 2.1.0, however, was above reels (2.0.8) but EQUAL to steady and - cappy (2.1.0) — which is why crash #2 was reproducible on reels and would not have appeared on - the other two. An epic that had only ever tested steady would have concluded Kermit was fine. - - Holding at Supabase 3.1.x puts PayCraft BELOW all three, so each consumer resolves up to its - own pin and keeps its own API shape — including reels' Postgrest calls, which is what crash #3 - (SelectRequestBuilder.) was. Removing Kermit outright removes that axis entirely rather - than pinning around it. Both fixes are now in the source and awaiting the consumer re-test. diff --git a/infra/deploy/deploy.sh b/infra/deploy/deploy.sh index c09d3a97..fd4cf38f 100755 --- a/infra/deploy/deploy.sh +++ b/infra/deploy/deploy.sh @@ -688,13 +688,26 @@ phase_6_smoke() { echo " ⚠ /auth/login HTTP $result — may not contain expected markers" fi - # Edge Function reachability — /config is the SDK's critical endpoint. No-auth probe: - # 401 = function deployed & auth-gated (correct); 404 = NOT deployed. + # Edge Function reachability — /config is the SDK's critical endpoint. This probe sends NO + # apiKey, so the function's CORRECT answer is a rejection, not a 200. What it distinguishes is + # "deployed and validating" from "not there at all": + # + # 400 missing_apiKey → deployed, validating its input (the no-arg probe's real answer) + # 401 invalid_apiKey → deployed, authenticating (a wrong key) + # 200 → deployed (only if a key were sent) + # 404 NOT_FOUND → NOT deployed ← the one real failure + # 000/5xx → unreachable / broken runtime + # + # 400 was missing from the accept list, so phase 6 FAILED a deploy whose every other phase had + # passed and whose function was healthy — measured 2026-10-06, where the probe returned + # `{"error":"missing_apiKey"}` and the chain aborted. Verified the same day: a function that + # genuinely does not exist returns 404 `{"code":"NOT_FOUND"}`, so the distinction is real and + # 404 remains a hard failure. result=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "https://${SUPABASE_REF}.supabase.co/functions/v1/config" 2>&1) || true - if [[ "$result" =~ ^(401|200)$ ]]; then - echo " ✓ Edge Function /config reachable (HTTP $result — deployed)" + if [[ "$result" =~ ^(400|401|200)$ ]]; then + echo " ✓ Edge Function /config reachable (HTTP $result — deployed + validating)" else - echo " ✗ Edge Function /config → HTTP $result (404 = not deployed)"; fails=$((fails+1)) + echo " ✗ Edge Function /config → HTTP $result (404 = not deployed, 000 = unreachable)"; fails=$((fails+1)) fi rm -f /tmp/.health.json /tmp/.login.html diff --git a/supabase/functions/config/index.ts b/supabase/functions/config/index.ts index 7cfaa5dd..f035b57e 100644 --- a/supabase/functions/config/index.ts +++ b/supabase/functions/config/index.ts @@ -90,6 +90,12 @@ function storeBindingFor( p: Record, method: string | null, currency: string | null, + /** + * TEST-mode caller? Stripe product/price ids are ACCOUNT-scoped, so a live id is meaningless to a + * test-mode client and vice versa (migration 147). Handing a `pk_test_` build the live price id is + * how a test checkout ends up transacting against live objects. + */ + isTest = false, ): { provider: string; product_id: string } | null { if (!method) return null const nonBlank = (v: unknown): string | null => { @@ -106,12 +112,18 @@ function storeBindingFor( } // Web PSPs: the transactable id is the per-currency price/plan id for the served currency. if (method.startsWith("stripe")) { - const byCur = (p.stripe_price_id_by_currency ?? {}) as Record + const byCur = (isTest + ? p.stripe_price_id_by_currency_test ?? {} + : p.stripe_price_id_by_currency ?? {}) as Record const id = nonBlank(currency ? byCur[currency] ?? byCur[currency.toUpperCase()] : null) ?? - nonBlank(p.stripe_product_id) + nonBlank(isTest ? p.stripe_product_id_test : p.stripe_product_id) return id ? { provider: method, product_id: id } : null } if (method.startsWith("razorpay")) { + // KNOWN GAP, deliberately unchanged here: migration 141 mode-scoped these columns and fixed the + // read in `dashboard/lib/checkout-initiator.ts`, but this one still reads the LIVE map for a + // test caller. Same defect class as the Stripe branch above, different provider — it belongs to + // a razorpay change with its own canary, not to 147. const byCur = (p.razorpay_plan_id_by_currency ?? {}) as Record const id = nonBlank(currency ? byCur[currency] ?? byCur[currency.toUpperCase()] : null) return id ? { provider: method, product_id: id } : null @@ -139,15 +151,16 @@ function storeBindingForChain( methods: string[], currency: string | null, platform: string | null, + isTest = false, ): { provider: string; product_id: string } | null { for (const m of methods) { - const b = storeBindingFor(p, m, currency) + const b = storeBindingFor(p, m, currency, isTest) if (b) return b } // Last resort 1: the store that owns this platform's digital lane. const nativeTail = platform === "ios" ? "app_store" : platform === "android" ? "google_play" : null if (nativeTail && !methods.includes(nativeTail)) { - const b = storeBindingFor(p, nativeTail, currency) + const b = storeBindingFor(p, nativeTail, currency, isTest) if (b) return b } @@ -164,7 +177,7 @@ function storeBindingForChain( // narrowing their chain; they were expressing a preference, not asking for the sale to be dropped. for (const tail of ["stripe_card", "razorpay"]) { if (methods.includes(tail) || tail === nativeTail) continue - const b = storeBindingFor(p, tail, currency) + const b = storeBindingFor(p, tail, currency, isTest) if (b) return b } return null @@ -509,7 +522,7 @@ export async function handleConfigRequest(req: Request): Promise { trial_duration_days: trialDurationDays, discount_percent: discountActive ? discountPercent : null, discount_ends_at: discountActive ? discountEndsAt : null, - store_binding: storeBindingForChain(p, methodChain, String(p.global_currency ?? ""), callerPlatform), + store_binding: storeBindingForChain(p, methodChain, String(p.global_currency ?? ""), callerPlatform, isTestMode), resolved_price: { amount_cents: p.global_price_cents, currency: p.global_currency, @@ -545,7 +558,7 @@ export async function handleConfigRequest(req: Request): Promise { trial_duration_days: trialDurationDays, discount_percent: discountActive ? discountPercent : null, discount_ends_at: discountActive ? discountEndsAt : null, - store_binding: storeBindingForChain(p, methodChain, String(resolved_price.currency ?? ""), callerPlatform), + store_binding: storeBindingForChain(p, methodChain, String(resolved_price.currency ?? ""), callerPlatform, isTestMode), resolved_price, // AC-16 — both chains travel on EVERY product row, always. A client that only ever sees // the served value cannot tell a correct price from a lucky one; carrying the shadow makes diff --git a/supabase/migrations/147_stripe_ids_mode_scoped.sql b/supabase/migrations/147_stripe_ids_mode_scoped.sql new file mode 100644 index 00000000..91121048 --- /dev/null +++ b/supabase/migrations/147_stripe_ids_mode_scoped.sql @@ -0,0 +1,129 @@ +-- 147_stripe_ids_mode_scoped.sql +-- +-- Mode-scope Stripe product + price ids. This is migration 141 applied to the provider 141 did not +-- cover. Same bug, same shape, same reasoning — Razorpay got the fix on 2026-09-2x and Stripe was +-- left on the broken pattern. +-- +-- THE BUG +-- `tenant_products.stripe_product_id` and `.stripe_price_id_by_currency` are single columns, written +-- by whichever sync mode ran last. `runProductSync` → `stripeSyncProduct` syncs EVERY configured +-- mode in one pass, and `modes` is built live-first: +-- +-- dashboard/lib/stripe-route-helper.ts +-- const modes = [live?, test?].filter(Boolean) -- live, then TEST +-- for (const mode of modes) { ... lastResult = result } +-- await supabase.rpc("tenant_products_set_stripe_ids", { ...lastResult }) +-- +-- The comment at that call site said it records "the last successful sync's ids, preferring live" — +-- but `lastResult` is the LAST iteration, and test is last. So on any tenant with both keys +-- configured, a routine sync ends with a TEST product and TEST price ids sitting in the fields LIVE +-- checkout reads (`supabase/functions/config/index.ts` → `stripe_price_id_by_currency`). +-- +-- MEASURED, not inferred (2026-10-06). Tenants cappy and reels-downloader, 7 products, every one +-- unreadable with the tenant's live key. Stripe's own answer, once the readback stopped discarding +-- it: +-- +-- No such product: 'prod_VJMNS10q3sZyzF'; a similar object exists in test mode, +-- but a live mode key was used to make this request +-- +-- Live Stripe checkout on both apps was pointed at test-mode objects. It presented as a drift +-- warning; it is a revenue outage. +-- +-- THE SHAPE +-- A second pair of columns rather than a restructure of the first — exactly 141, which in turn +-- matches how `tenant_providers` has always modelled this (`live_payment_links` / +-- `test_payment_links`). It does not rewrite the columns live checkout reads on every purchase. +-- +-- WHAT HAPPENS TO THE EXISTING VALUES +-- They are UNATTRIBUTABLE — nothing recorded which mode wrote them — so, per 141, they are neither +-- trusted as live nor deleted: +-- • left in place, because clearing them would break live checkout in the window before the next +-- sync for every row that DOES hold a live id, trading a latent risk for a certain outage; +-- • the next LIVE sync overwrites them with verified live ids, and the next TEST sync fills the +-- new columns, after which both are correct by construction. +-- One full sync converges. Until then `live_stripe_ids_verified` stays false, and a consumer that +-- needs a trustworthy answer reads the flag rather than the column. +-- +-- Note the difference from 141, stated because it is tempting to act on: for cappy and +-- reels-downloader we have PROOF the current values are test-mode, so "latent risk" is already a +-- live break there. Clearing them is still not this migration's job — it cannot prove the same for +-- every other row, and a targeted repair is a backfill decision for an operator with the drift +-- report in hand, not a schema change. The flag is what makes those rows identifiable. + +BEGIN; + +ALTER TABLE tenant_products + ADD COLUMN IF NOT EXISTS stripe_product_id_test TEXT, + ADD COLUMN IF NOT EXISTS stripe_price_id_by_currency_test JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Distinguishes "a live sync wrote this" from "something wrote this before 147". Without it the + -- ambiguous legacy values are indistinguishable from verified ones the moment this ships. + ADD COLUMN IF NOT EXISTS live_stripe_ids_verified BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN tenant_products.stripe_product_id IS + 'LIVE-mode Stripe Product id (prod_...). Before migration 147 this column was written by ' + 'whichever sync mode ran last, so rows with live_stripe_ids_verified = false may hold a TEST id.'; +COMMENT ON COLUMN tenant_products.stripe_price_id_by_currency IS + 'LIVE-mode Stripe Price ids, {currency: price_id}. See the caveat on stripe_product_id — rows ' + 'with live_stripe_ids_verified = false may hold TEST price ids.'; +COMMENT ON COLUMN tenant_products.stripe_product_id_test IS + 'TEST-mode Stripe Product id. Added by 147; the live twin is stripe_product_id.'; +COMMENT ON COLUMN tenant_products.stripe_price_id_by_currency_test IS + 'TEST-mode Stripe Price ids, {currency: price_id}. Added by 147; live twin is stripe_price_id_by_currency.'; +COMMENT ON COLUMN tenant_products.live_stripe_ids_verified IS + 'TRUE once a LIVE-mode Stripe sync wrote stripe_product_id / stripe_price_id_by_currency. FALSE ' + 'means the values predate 147 and their mode is unknown — they may be test-mode ids.'; + +-- The setter gains a mode. The old three-argument version is DROPPED rather than left as an +-- overload: keeping it would let any caller that was not updated keep writing the live columns from +-- a test sync — the exact defect being fixed — and PostgREST would resolve to it silently. +DROP FUNCTION IF EXISTS public.tenant_products_set_stripe_ids(uuid, text, jsonb); + +CREATE OR REPLACE FUNCTION public.tenant_products_set_stripe_ids( + p_id UUID, + p_stripe_product_id TEXT, + p_stripe_price_id_by_currency JSONB, + p_mode TEXT +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +DECLARE v_tenant UUID; +BEGIN + SELECT tenant_id INTO v_tenant FROM tenant_products WHERE id = p_id; + IF v_tenant IS NULL THEN RAISE EXCEPTION 'unknown_product'; END IF; + + IF auth.role() IS DISTINCT FROM 'service_role' + AND NOT EXISTS ( + SELECT 1 FROM tenant_admins WHERE tenant_id = v_tenant AND user_id = auth.uid() + ) THEN RAISE EXCEPTION 'forbidden'; END IF; + + IF p_mode NOT IN ('live', 'test') THEN + -- No default. A caller that does not know its mode must not be allowed to guess, because the + -- wrong guess is precisely what put test product ids in front of live customers. + RAISE EXCEPTION 'invalid_mode:%', p_mode; + END IF; + + IF p_mode = 'live' THEN + UPDATE tenant_products + SET stripe_product_id = p_stripe_product_id, + stripe_price_id_by_currency = p_stripe_price_id_by_currency, + live_stripe_ids_verified = TRUE, + updated_at = now() + WHERE id = p_id; + ELSE + UPDATE tenant_products + SET stripe_product_id_test = p_stripe_product_id, + stripe_price_id_by_currency_test = p_stripe_price_id_by_currency, + updated_at = now() + WHERE id = p_id; + END IF; +END; +$function$; + +REVOKE ALL ON FUNCTION public.tenant_products_set_stripe_ids(UUID, TEXT, JSONB, TEXT) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.tenant_products_set_stripe_ids(UUID, TEXT, JSONB, TEXT) + TO authenticated, service_role; + +COMMIT;