Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c053822
feat: wire pilot guardrails into agent runtime, close approval bypass
mtmtian Jul 7, 2026
862fc02
feat: render growth snapshot into plan prompt
mtmtian Jul 7, 2026
14241bf
docs(platform): mmp ingest guide — three holes before adjust/appsflyer
mtmtian Jul 7, 2026
7140061
feat: adjust s2s ingest with single install-source authority
mtmtian Jul 7, 2026
b579b18
feat(db,api): signup-anchored cohorts, os/agency dims, bi summary + b…
mtmtian Jul 7, 2026
467456c
feat(ui): dashboard bi view — date range, filters, field config, two …
mtmtian Jul 7, 2026
3a832d9
feat: campaign-name canon — agency/bid/goal parsing + funnel-delivery…
mtmtian Jul 7, 2026
42a2d86
docs(platform): correct adjust linking-key term — callback parameter,…
mtmtian Jul 7, 2026
02a702a
docs(platform): status handoff — record fork PR chain #4–#7
mtmtian Jul 7, 2026
f14b638
fix: pilot cap sums account-level rows only; persist fresh report on …
mtmtian Jul 9, 2026
ea28d5d
Merge branch 'feat/pilot-guardrails-runtime' into feat/adjust-ingest
mtmtian Jul 9, 2026
23dc9a8
fix: adjust authority from live s2s events; wire event-token map; spl…
mtmtian Jul 9, 2026
7f05dee
fix: validate event-token map values against the canonical event set
mtmtian Jul 9, 2026
bb4408d
Merge branch 'feat/adjust-ingest' into feat/dashboard-bi
mtmtian Jul 9, 2026
7faaf29
fix: retention maturity waits for day N to end; bound breakdown query…
mtmtian Jul 9, 2026
4bd7d11
Merge branch 'feat/dashboard-bi' into feat/campaign-canon
mtmtian Jul 9, 2026
7e2b79c
fix: joined-spend costPerSignup, agency case normalization (review #7)
mtmtian Jul 9, 2026
091ed37
chore: gitignore local design PDFs
mtmtian Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ MAIL_FROM="Adex <no-reply@your-domain.com>"
# openssl rand -hex 32
CRON_SECRET=""

# Growth ingest secrets (org-level PlatformAuth rows override these fallbacks).
# INGEST_WEBHOOK_SECRET — HMAC signing key for POST /api/ingest/events.
# INGEST_ADJUST_SECRET — static token for GET|POST /api/ingest/adjust. MUST be
# distinct from INGEST_WEBHOOK_SECRET: the Adjust token travels in cleartext
# query strings (lands in access logs) and must not unlock HMAC forgery.
INGEST_WEBHOOK_SECRET=""
INGEST_ADJUST_SECRET=""

# Demo seed config (used by `npm run db:seed`).
# - SEED_EMAIL: defaults to demo@adexads.com if unset.
# - SEED_PASSWORD: if unset, the seed script generates a cryptographically
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,8 @@ dev.db

# Local Claude Code preview server config
.claude/launch.json

# Local-only design/reference docs — business material, never commit (see
# ~/.claude data-handling rules). Keep such files out of the repo tree.
docs/adex-google-ads-api-design-document.pdf
docs/**/*.pdf
15 changes: 13 additions & 2 deletions docs/growth/04-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
Three stacked PRs — **merge in order 1 → 2 → 3**:

| PR | Scope | Branch |
|---|---|---|
| --- | --- | --- |
| [#1](https://github.com/oratis/adex/pull/1) | Growth OS P18–P19 + loopback design language | `feat/growth-cuddler-pilot` → `main` |
| [#2](https://github.com/oratis/adex/pull/2) | Creative Studio (物料 capability) | `feat/growth-creative-studio` → #1 |
| [#3](https://github.com/oratis/adex/pull/3) | P21 Growth Agent | `feat/growth-agent-p21` → #2 |
Expand All @@ -21,7 +21,7 @@ Every decision/attribution/spec/validation module is pure + unit-tested under `s
Each item's pure logic is done and covered by tests; what's left is wiring the credentialed call.

| Seam | Decision layer ready | Needs |
|---|---|---|
| --- | --- | --- |
| **Adapter app-install writes** — real `OUTCOME_APP_PROMOTION` / `APP_PROMOTION` createCampaign | `campaign-objective.ts` (resolve + validate) + launch-route gate | Meta/TikTok/Google sandbox creds; adapter `launchCampaign` app-promotion branch |
| **Creative render** — variant → actual Seedance2/Seedream job → transcode/crop → platform push | `storyboard.ts`, `creative-specs.ts` (validate/needs_transcode), `studio/produce` (creates the Creative + prompt) | `SEEDANCE2_API_KEY` / Seedream; a transcode worker |
| **GA4 event-level pull** | `ga4.ts` mapper + `ingest/events` push (the real-time path) | GA4 **BigQuery export** for user-level events (Data API is aggregate-only) — or rely on the push channel |
Expand All @@ -35,3 +35,14 @@ Each item's pure logic is done and covered by tests; what's left is wiring the c
## Suggested next milestone

Once a platform sandbox + `SEEDANCE2_API_KEY` are available: wire the adapter app-install branch + the produce→render→push loop end-to-end against a test ad account — the first real $5K-pilot campaign created entirely inside Adex.

## 2026-07-07 · fork contributions (PR #4–#7, stacked, merge in order)

| PR | Scope | Key change |
| --- | --- | --- |
| [#4](https://github.com/oratis/adex/pull/4) | agent runtime | The four pilot disciplines wired as real guardrail evaluators; `executeApprovedDecision` re-checks guardrails at execution time (bypass closed); growth snapshot now rendered into the plan prompt |
| [#5](https://github.com/oratis/adex/pull/5) | data ingest | Adjust S2S callback → ConversionEvent with single install-source authority (anti-double-count), `ADJUST_NETWORK_MAP` channel mapping, namespaced userKey |
| [#6](https://github.com/oratis/adex/pull/6) | canon + BI | Signup-anchored cohorts (authority never filters signup), os/agency dims, D0/D7 revenue windows, spend injection (CAC no longer null), mature-window retention gating, `/api/growth/summary` + `/api/reports/breakdown`, dashboard BI view |
| [#7](https://github.com/oratis/adex/pull/7) | attribution | Campaign-name canon (`agency-date-bid-os-regions-channel-...`) parsed into agency/bidStrategy/conversionGoal; funnel↔delivery join on `(date, os, platform, agency)` |

Pipeline canon lives in [06-mmp-ingest.md](06-mmp-ingest.md) §6–§7. Ops prerequisites for live data: Adjust callback + `app_user_id` **callback parameter** (not partner parameter), backend pushes signup/payment with `source=backend`, campaign naming discipline from day one.
136 changes: 136 additions & 0 deletions docs/growth/06-mmp-ingest.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions prisma/migrations/20260707120000_bi_breakdown/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- BI breakdown (docs/growth/06-mmp-ingest.md §6) — os dimension + signup-anchored
-- cohort fields + agency dimension on Report/PlatformAccount. Additive only.

-- AlterTable
ALTER TABLE "ConversionEvent" ADD COLUMN "os" TEXT;

-- AlterTable
ALTER TABLE "Report" ADD COLUMN "os" TEXT;
ALTER TABLE "Report" ADD COLUMN "agency" TEXT;

-- AlterTable
ALTER TABLE "PlatformAccount" ADD COLUMN "agency" TEXT;

-- AlterTable
ALTER TABLE "CohortSnapshot" ADD COLUMN "os" TEXT;
ALTER TABLE "CohortSnapshot" ADD COLUMN "signups" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "CohortSnapshot" ADD COLUMN "revenueD0" DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE "CohortSnapshot" ADD COLUMN "revenueD7" DOUBLE PRECISION NOT NULL DEFAULT 0;

-- DropIndex (widen unique key to include os — see schema.prisma CohortSnapshot
-- doc comment for the Postgres NULL-uniqueness caveat this inherits)
DROP INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_key";

-- CreateIndex
CREATE UNIQUE INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_os_key" ON "CohortSnapshot"("orgId", "appId", "cohortDate", "channel", "os");
20 changes: 20 additions & 0 deletions prisma/migrations/20260707130000_campaign_name_canon/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Campaign-name canon (docs/growth/06-mmp-ingest.md §7) — agency / bid
-- strategy / conversion goal parsed from campaign_name, plus the agency
-- dimension on CohortSnapshot needed to join cohort attribution back to
-- Report spend by agency. Additive only.

-- AlterTable
ALTER TABLE "ConversionEvent" ADD COLUMN "agency" TEXT;
ALTER TABLE "ConversionEvent" ADD COLUMN "bidStrategy" TEXT;
ALTER TABLE "ConversionEvent" ADD COLUMN "conversionGoal" TEXT;

-- AlterTable
ALTER TABLE "CohortSnapshot" ADD COLUMN "agency" TEXT;

-- DropIndex (widen unique key to include agency — see schema.prisma
-- CohortSnapshot doc comment for the Postgres NULL-uniqueness caveat this
-- inherits, same as the `os` widening before it)
DROP INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_os_key";

-- CreateIndex
CREATE UNIQUE INDEX "CohortSnapshot_orgId_appId_cohortDate_channel_os_agency_key" ON "CohortSnapshot"("orgId", "appId", "cohortDate", "channel", "os", "agency");
40 changes: 39 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ model PlatformAccount {
accessToken String?
refreshToken String?
extra String? // JSON
// Media-buying agency of record for this account, when one exists (bi
// breakdown dimension). Null when the org buys direct.
agency String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down Expand Up @@ -407,6 +410,9 @@ model Report {
cpc Float @default(0)
cpa Float @default(0)
roas Float @default(0)
// BI breakdown dimensions (bi §6). Null for platforms/rows we can't stamp.
os String? // ios | android | web — account-level MMP rows leave this null
agency String? // mirrored from PlatformAccount.agency when known
rawData String? // JSON string of full API response
createdAt DateTime @default(now())

Expand Down Expand Up @@ -807,8 +813,15 @@ model ConversionEvent {
utmSource String?
utmCampaign String?
channel String? // paid_meta_web | paid_asa | kol | organic | ...
os String? // ios | android | web
country String?
revenue Float @default(0) // net revenue for subscription events
// Campaign-name-derived dimensions (docs/growth/06-mmp-ingest.md §7): parsed
// from the MMP callback's campaign_name via campaign-name.ts. Null when the
// campaign name is missing or doesn't parse (not guessed).
agency String?
bidStrategy String?
conversionGoal String?
raw String? // JSON
createdAt DateTime @default(now())

Expand All @@ -828,20 +841,45 @@ model CohortSnapshot {
appId String?
cohortDate DateTime // acquisition day
channel String
// Anchored user's OS (ios | android | web), null when unknown/pre-dates the
// bi rollout. Included in the group key (bi §6) — see the null-uniqueness
// caveat below.
os String?
// Campaign-name-derived agency for the anchored user's attributed install
// event (docs/growth/06-mmp-ingest.md §7), same source/priority as
// ConversionEvent.agency. Null when unknown. Included in the group key.
agency String?
computedAt DateTime @default(now())
installs Int @default(0)
// Users anchored on a signup event (bi §6 acquisition-anchor rule) rather
// than an install — mutually exclusive with `installs` per user (a user
// with both a signup and an install anchors on the signup and is only
// counted in `signups`). installs + signups = row's anchored user count.
signups Int @default(0)
activated Int @default(0) // first_chat completed
d1Retained Int @default(0)
d7Retained Int @default(0)
trials Int @default(0)
subscribers Int @default(0)
revenueToDate Float @default(0)
// Revenue windows off the cohort anchor day (bi §6): D0 = anchor day only,
// D7 = anchor day .. +7d inclusive. Subsets of revenueToDate.
revenueD0 Float @default(0)
revenueD7 Float @default(0)
ltvEstimate Float @default(0)
cac Float? // paid channels: spend / installs

org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)

@@unique([orgId, appId, cohortDate, channel])
// NOTE on null uniqueness: Postgres treats each NULL in a unique index as
// distinct, so multiple rows with the same (orgId, appId, cohortDate,
// channel, os=NULL) would NOT violate this constraint if written via
// upsert-by-key. We rely on the growth-sync cron's delete-then-createMany
// pattern (never an upsert keyed on this constraint) to stay collision-free
// regardless — see src/app/api/cron/growth-sync/route.ts. Same caveat
// already applied to `appId` before this migration; `os` and `agency`
// follow it.
@@unique([orgId, appId, cohortDate, channel, os, agency])
@@index([orgId, cohortDate])
}

Expand Down
Loading