diff --git a/.claude/WORKFLOW.md b/.claude/WORKFLOW.md
new file mode 100644
index 00000000..1c5fa647
--- /dev/null
+++ b/.claude/WORKFLOW.md
@@ -0,0 +1,163 @@
+# AppsFlyer Flutter Plugin β AI Skill Workflow
+
+> Last updated: auto-generated
+
+Describes how the Claude Code skills communicate and which `docs/` directories each one reads or writes.
+
+---
+
+## Skill Communication & Docs Access
+
+```mermaid
+flowchart TD
+ User(["π€ User"])
+
+ User -->|"/af-ship"| Orch
+ User -->|"/af-ship-from-prd"| Orch
+ User -->|"/af-ship-from-tech-design"| Orch
+ User -->|"maintenance task"| Dave
+
+ Orch["π¦ af-ship-orch
Entry router
Creates tasks Β· fetches docs"]
+ Alice["π©βπΌ Alice PM
alice-pm
Owns: WHAT"]
+ Bob["π¨βπ¬ Bob
bob-flutter-researcher
Owns: R-NNN docs"]
+ Erin["π©βπ» Erin
erin-flutter-analyst
Owns: P-NNN docs"]
+ Dave["π¨βπ» Dave
dave-flutter-engineer
Owns: HOW + F-NNN docs"]
+
+ Orch -->|"delegates to"| Alice
+ Alice -->|"platform / API unclear"| Bob
+ Alice -->|"payloads / contracts affected"| Erin
+ Bob -->|"findings"| Alice
+ Erin -->|"field map"| Alice
+ Alice -->|"after Bob/Erin satisfied"| Dave
+ Dave -->|"code / tech design"| Alice
+ Alice -->|"unresolved after 2x"| User
+
+ subgraph docs ["π docs/"]
+ PRDs["prds/
PRDs (staging)"]
+ TechDesigns["tech-designs/
Tech designs (staging)"]
+ Features["features/
F-NNN Β· feature catalog"]
+ IssueCases["issue-cases/
IC-NNN Β· scar book
GUARDRAILS.md"]
+ Researches["researches/
R-NNN Β· research log"]
+ Payloads["payloads/
P-NNN Β· field maps"]
+ end
+
+ Orch -.->|"saves fetched PRD"| PRDs
+ Orch -.->|"saves fetched tech design"| TechDesigns
+ Alice -.->|writes| PRDs
+ Alice -.->|reads| Features
+ Alice -.->|reads| IssueCases
+ Dave -.->|writes| TechDesigns
+ Dave -.->|reads + writes| Features
+ Dave -.->|reads| IssueCases
+ Bob -.->|writes| Researches
+ Bob -.->|reads| Features
+ Bob -.->|reads| IssueCases
+ Erin -.->|writes| Payloads
+ Erin -.->|reads| Features
+ Erin -.->|reads| IssueCases
+```
+
+**Solid arrows** = skill invocation (who calls whom).
+**Dotted arrows** = docs read/write access.
+
+---
+
+## Docs Layer β Who Owns What
+
+| Directory | Nickname | Owner | Consumers |
+|-----------|----------|-------|-----------|
+| `internal-docs/prds/` | PRDs (staging) | Alice (writes); af-ship-orch (saves external) | User review; may move to Notion |
+| `internal-docs/tech-designs/` | Tech designs (staging) | Dave (writes); af-ship-orch (saves external) | User review; may move to Notion |
+| `internal-docs/features/` | Feature catalog | Dave (writes F-NNN) | Alice, Bob, Erin (read) |
+| `internal-docs/issue-cases/` | Scar book | Human / eng team | Alice, Dave, Bob, Erin (read) |
+| `internal-docs/researches/` | Research log | Bob (writes R-NNN) | Alice (via challenge loop) |
+| `docs/payloads/` | Payload map | Erin (writes P-NNN, FIELD_MAP) | Alice, Dave (via challenge loop) |
+
+---
+
+## Invocation Rules
+
+| Entry point | When |
+|-------------|------|
+| `/af-ship ` | Starting a new feature from scratch |
+| `/af-ship --prd ` | Starting from an existing PRD (Notion URL or local .md) |
+| `/af-ship --tech-design ` | Starting from an existing tech design (Notion URL or local .md) |
+| `/af-ship-from-prd ` | Same as `--prd` flag; dedicated command alternative |
+| `/af-ship-from-tech-design ` | Same as `--tech-design` flag; dedicated command alternative |
+| Dave (direct) | Maintenance only: logs, renames, dead-code removal, comment cleanup, test additions, minor refactors with no public API change |
+| Bob (direct) | Ad-hoc platform/API research not tied to a feature |
+| Erin (direct) | Ad-hoc payload or schema analysis not tied to a feature |
+| Bob | Invoked by Alice when platform API / version / external behavior is unclear |
+| Erin | Invoked by Alice when payloads, request fields, or server-visible schema is affected |
+
+If unsure whether a task is maintenance or a feature β use `/af-ship`.
+
+---
+
+## Loop Mechanics
+
+**New feature from scratch:**
+```
+/af-ship
+ β af-ship-orch creates task wizard β calls alice-pm
+ β Alice writes PRD β saves to internal-docs/prds/.md β asks user to review
+ β User approves PRD
+ β Alice invokes Bob and/or Erin if needed
+ β Bob/Erin produce findings β Alice challenges (max 2 iterations)
+ β Alice updates PRD if scope changed β Alice invokes Dave
+ β [Phase 1 / 2 / 3 below]
+```
+
+**From existing PRD:**
+```
+/af-ship-from-prd (or /af-ship --prd )
+ β af-ship-orch fetches / reads PRD β saves to internal-docs/prds/.md β calls alice-pm
+ β Alice challenges PRD for completeness β resolves gaps with user
+ β Alice delegates to Bob/Erin/Dave (no second review pause)
+ β [Phase 1 / 2 / 3 below]
+```
+
+**From existing tech design:**
+```
+/af-ship-from-tech-design (or /af-ship --tech-design )
+ β af-ship-orch fetches / reads tech design β saves to internal-docs/tech-designs/.md β calls alice-pm
+ β Alice runs full challenge agenda β Dave addresses issues (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready."
+ β [Phase 2 / 3 below β Phase 1 skipped, PRD gate bypassed]
+```
+
+**Phase 1 β Tech design**
+```
+ β Dave writes tech design β saves to internal-docs/tech-designs/.md
+ β Alice challenges tech design (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready."
+ β Dave asks user to review tech design
+ β User approves tech design
+```
+
+**Phase 2 β Implementation**
+```
+ β Dave implements + writes unit tests
+ β Alice challenges implementation (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready."
+```
+
+**Phase 3 β Feature doc**
+```
+ β Dave runs impact scan β updates any affected existing F-NNN docs
+ β Dave writes new F-NNN feature doc β saves to internal-docs/features/
+ β Alice challenges feature doc (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready."
+```
+
+Escalation: if any item is unresolved after 2 full challenge loops β Alice escalates to User.
+
+---
+
+## Authority Map
+
+| Question | Owner |
+|----------|-------|
+| WHY β strategy, business goal | User (escalated by Alice) |
+| WHAT β requirements, scope, acceptance criteria | Alice |
+| HOW β architecture, implementation, tech tradeoffs | Dave |
diff --git a/.claude/commands/af-quiz-me.md b/.claude/commands/af-quiz-me.md
new file mode 100644
index 00000000..e58f4bcb
--- /dev/null
+++ b/.claude/commands/af-quiz-me.md
@@ -0,0 +1,115 @@
+Generate an interactive HTML quiz from a tech design document.
+
+## Step 1 β Resolve the document
+
+**If $ARGUMENTS is empty:**
+List all `.md` files in `internal-docs/tech-designs/`.
+- If files exist, list them and ask:
+ "Which tech design should I quiz you on? (Reply with the number or filename)
+ Or reply **project** to generate a quiz covering the whole project from the feature catalog."
+ Wait for the user's selection before continuing.
+- If the folder does not exist or is empty, ask:
+ "No tech designs found in `internal-docs/tech-designs/`. What would you like to do?
+ 1. Provide a path or Notion URL (reply with the path/URL)
+ 2. Generate a project quiz from the feature catalog (reply **project**)"
+ Wait for the user's reply before continuing.
+
+**If the user replies `project` (or $ARGUMENTS is `project`):**
+Check whether `internal-docs/features/INDEX.md` exists.
+- If it does not exist, stop and say:
+ "No feature catalog found. Run `/af-generate-feature-catalog` first to build `internal-docs/features/`, then try again."
+- If it exists, read `internal-docs/features/INDEX.md` to get the full list of features, then read each individual `internal-docs/features/F-*.md` file.
+ Set `` to `project` and `` to the project name derived from `INDEX.md` (e.g. `MyProject β Project Quiz`).
+ Proceed to Step 3 in **project mode** (random 10 questions across all features).
+
+**If $ARGUMENTS is provided (and not `project`):**
+- Starts with `http` β fetch using the `notion-fetch` MCP tool.
+- Otherwise β read the file at the given path directly.
+
+## Step 2 β Derive the feature slug and title
+
+From the document title or filename, derive:
+- `` β kebab-case short name (e.g. `sharedprefs-encryption`)
+- `` β human-readable title for display (e.g. `SharedPreferences Encryption`)
+
+## Step 3 β Generate 10 quiz questions
+
+Read the resolved document(s) in full. Generate exactly 10 questions as a JSON array
+using this exact structure:
+
+```json
+[
+ {
+ "q": "Question text",
+ "opts": ["Option A", "Option B", "Option C", "Option D"],
+ "ans": 2,
+ "exp": "One-sentence explanation of why the correct answer is correct."
+ }
+]
+```
+
+- `ans` is the zero-based index of the correct option (0β3).
+- Every question must have exactly 4 options.
+
+### Answer position distribution
+
+Before writing the JSON, randomly assign a correct answer position (0β3) for
+each of the 10 questions. No single index may appear more than 3 times across
+the set, ensuring the correct answers are spread across A, B, C, and D.
+
+For each question, place the correct option at its assigned position and fill
+the remaining slots with distractors. Set `ans` to match.
+
+Never write all questions with the correct answer at index 0 β this is the
+natural default when drafting distractors after the correct answer, and it
+must be explicitly overridden.
+
+### What to quiz on
+
+**Tech design mode** β focus on:
+- Business problem and motivation β why this feature exists
+- Customer or user impact β who benefits and how
+- Product goals and success criteria β what done looks like
+- Scope and non-goals β what is in vs out
+- Key decisions and tradeoffs β why the chosen approach over alternatives
+- Risks and mitigations β what could go wrong and how it is handled
+- Integration and rollout β how this lands in the product
+
+**Project mode** β pick 10 questions randomly across all features, covering:
+- What a feature does and why it exists (Business Purpose)
+- What the product loses if a feature is removed
+- How features depend on or interact with each other
+- What triggers a feature and what it produces
+- Known limitations or platform gaps
+- Ensure broad spread: do not pick more than 2 questions from the same feature
+
+### What NOT to quiz on
+
+- Exact field names, formula strings, or API parameter names
+- Specific numeric constants or thresholds (unless they represent a product decision)
+- Low-level implementation details only the author would know
+- Trivia answerable by ctrl+F rather than understanding
+
+## Step 4 β Build the output file
+
+Read the template from `templates/af-tech-quiz-template.html`.
+
+Replace both placeholders:
+- `{{QUIZ_TITLE}}` β the human-readable title from Step 2 (appears twice: in and in JS)
+- `{{QUESTIONS_JSON}}` β the full JSON array from Step 3 (no trailing semicolon β the template already has one)
+
+Create the output directory if it does not exist:
+```bash
+mkdir -p output.af-tech-quiz
+```
+
+Write the result to `output.af-tech-quiz/af-tech-quiz-.html`.
+
+## Step 5 β Open in browser
+
+Run:
+```bash
+open output.af-tech-quiz/af-tech-quiz-.html
+```
+
+Then tell the user: "Quiz saved to `output.af-tech-quiz/af-tech-quiz-.html` and opened in your browser."
diff --git a/.claude/commands/af-ship-from-prd.md b/.claude/commands/af-ship-from-prd.md
new file mode 100644
index 00000000..7224dbd0
--- /dev/null
+++ b/.claude/commands/af-ship-from-prd.md
@@ -0,0 +1,12 @@
+If $ARGUMENTS is empty, stop and ask:
+"Please provide a Notion URL or a path to a local .md file for the PRD.
+Example: `/af-ship-from-prd https://notion.so/team/my-prd`
+Example: `/af-ship-from-prd internal-docs/prds/my-feature.md`"
+Do not proceed until the user provides a URL or path.
+
+Start the feature delivery workflow using an existing PRD.
+The PRD source is: $ARGUMENTS
+
+Invoke the `af-ship-orch` skill now in PRD-Given mode.
+It will fetch and save the PRD, then call Alice to challenge it for completeness,
+resolve gaps with you, and delegate to Bob/Erin/Dave.
diff --git a/.claude/commands/af-ship-from-tech-design.md b/.claude/commands/af-ship-from-tech-design.md
new file mode 100644
index 00000000..4516843b
--- /dev/null
+++ b/.claude/commands/af-ship-from-tech-design.md
@@ -0,0 +1,12 @@
+If $ARGUMENTS is empty, stop and ask:
+"Please provide a Notion URL or a path to a local .md file for the tech design.
+Example: `/af-ship-from-tech-design https://notion.so/team/my-design`
+Example: `/af-ship-from-tech-design internal-docs/tech-designs/my-feature.md`"
+Do not proceed until the user provides a URL or path.
+
+Start the delivery workflow using an existing tech design.
+The tech design source is: $ARGUMENTS
+
+Invoke the `af-ship-orch` skill now in Tech-Design-Given mode.
+It will fetch and save the tech design, then call Alice to run her full challenge agenda,
+work with Dave to resolve any issues, then proceed to implementation after your approval.
diff --git a/.claude/commands/af-ship.md b/.claude/commands/af-ship.md
new file mode 100644
index 00000000..d75be3be
--- /dev/null
+++ b/.claude/commands/af-ship.md
@@ -0,0 +1,40 @@
+Check $ARGUMENTS for flags before doing anything else:
+
+**If $ARGUMENTS starts with `--prd `:**
+Extract the URL or path that follows `--prd `.
+If nothing follows `--prd`, stop and ask:
+"Please provide a Notion URL or local .md path after --prd
+(e.g. `/af-ship --prd https://notion.so/team/my-prd`)."
+Do not proceed until a URL or path is provided.
+Otherwise: invoke the `af-ship-orch` skill in PRD-Given mode.
+The PRD source is the value extracted from $ARGUMENTS after `--prd `.
+
+**If $ARGUMENTS starts with `--tech-design `:**
+Extract the URL or path that follows `--tech-design `.
+If nothing follows `--tech-design`, stop and ask:
+"Please provide a Notion URL or local .md path after --tech-design
+(e.g. `/af-ship --tech-design internal-docs/tech-designs/my-feature.md`)."
+Do not proceed until a URL or path is provided.
+Otherwise: invoke the `af-ship-orch` skill in Tech-Design-Given mode.
+The tech design source is the value extracted from $ARGUMENTS after `--tech-design `.
+
+**If $ARGUMENTS starts with `--` (unrecognized flag):**
+Stop and ask:
+"Unrecognized flag. Supported flags are:
+- `--prd ` β start from an existing PRD
+- `--tech-design ` β start from an existing tech design
+Or provide a feature description directly (e.g. `/af-ship add dark mode`)."
+Do not proceed.
+
+**If $ARGUMENTS contains no flags (default β new feature from scratch):**
+If $ARGUMENTS is empty or contains only one word, stop and ask:
+"What feature would you like to implement? Please give a short description
+(e.g. `/af-ship add dark mode to settings screen`)."
+Do not proceed until the user provides a description.
+Otherwise, start the full feature delivery workflow for the following feature:
+
+$ARGUMENTS
+
+Invoke the `af-ship-orch` skill now to begin. It will set up the workflow tasks,
+then hand off to Alice to write a PRD, coordinate research and engineering
+through tech design, implementation, and feature documentation.
diff --git a/.claude/prompts/generate-feature-catalog.md b/.claude/prompts/generate-feature-catalog.md
new file mode 100644
index 00000000..8b5fc51f
--- /dev/null
+++ b/.claude/prompts/generate-feature-catalog.md
@@ -0,0 +1,325 @@
+# Prompt: Create Feature Catalog
+
+Use this prompt to generate a `internal-docs/features/` catalog for this project.
+Values below are filled during workflow setup β edit them here if needed.
+
+---
+
+## Inputs
+
+```
+PROJECT_CONTEXT: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel
+LANGUAGES: Dart, Objective-C, Java, Kotlin
+NOTION_DB_URL:
+NOTION_KEYWORDS:
+JIRA_PROJECT_KEY: DELIVERY
+```
+
+---
+
+## Prompt
+
+````
+Create a feature catalog for this project under `internal-docs/features/`.
+
+Project context: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel
+Primary language(s): Dart, Objective-C, Java, Kotlin
+
+---
+
+## Step 0 β Create workflow tasks
+
+Call `TaskCreate` for each step in order to give a live progress view:
+
+| Subject | activeForm |
+|---------|------------|
+| Check docs & external sources | Checking availability |
+| Discover features from code | Scanning codebase |
+| Verify & prune feature list | Verifying features |
+| User reviews feature list | Waiting for approval |
+| Propose taxonomy | Proposing categories |
+| User reviews taxonomy | Waiting for approval |
+| Write feature catalog | Writing feature docs |
+| Dependency audit | Auditing dependencies |
+| Notion enrichment | Enriching from Notion |
+| Jira enrichment | Enriching from Jira |
+
+Immediately mark "Check docs & external sources" as `in_progress`.
+
+---
+
+## Phase 0 β Check web docs and Notion availability
+
+### Part A β Web docs (optional β edit the list below before running)
+
+WEB_DOCS_URLS:
+ (none β add official documentation URLs here if available, one per line)
+
+If no URLs are listed above, say "No web docs URL provided β skipping Phase 0A" and proceed to Part B.
+
+If URLs are listed above:
+1. Fetch the main page of each URL.
+2. Discover the navigation structure (sitemap, sidebar links, category pages).
+3. Build a list of relevant sub-pages whose titles match the project domain. Keep this list in memory β do NOT fetch sub-pages yet.
+4. Say: "Web docs detected. Found N candidate pages. Context will be fetched per-feature during Phase 3 Business Purpose writing."
+
+Do not fetch sub-pages now. Proceed to Part B.
+
+### Part B β Check Notion availability
+
+If NOTION_DB_URL is provided, say:
+"Notion URL detected. Business Purpose enrichment will happen in Phase 4, after the catalog is built. Proceeding to Phase 1."
+Then proceed to Phase 1. Do not fetch Notion yet.
+
+If NOTION_DB_URL is blank, pause and say exactly:
+
+> **Action required β Notion enrichment**
+>
+> The **Business Purpose** section is the most valuable part of each feature doc β it answers "what does the product lose if this feature is deleted?" Code alone rarely answers that question; it lives in product specs, PRDs, and design documents.
+>
+> If your team stores specs or PRDs in Notion, providing a database URL now means every feature doc gets its Business Purpose enriched automatically in Phase 4.
+>
+> - **Do you have a Notion database with product specs or PRDs for this project?**
+> - Reply with the Notion database URL to enable enrichment. You can also add keywords to filter pages (e.g. `launch, attribution, session`) β if you don't, **`Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel`** (the project name) will be used as the default filter.
+> - Reply **skip** to proceed without Notion β Business Purpose sections will be derived from code only and marked `> TODO: enrich from product specs`.
+
+Wait for the user's reply before continuing.
+- If they provide a URL: store it as NOTION_DB_URL. If they also provided keywords store them as NOTION_KEYWORDS; otherwise set NOTION_KEYWORDS to the project name from PROJECT_CONTEXT. Confirm "Notion enrichment enabled. Proceeding to Phase 1." and proceed.
+- If they reply **skip**: say "Proceeding without Notion. Business Purpose sections will be marked TODO." and proceed to Phase 1.
+
+---
+
+## Phase 1 β Discover features from code (no classification yet)
+
+Mark "Check docs & external sources" as completed. Mark "Discover features from code" as `in_progress`.
+
+Read the project's public interfaces, entry points, core implementation files, and any existing documentation under `docs/`. Scan every subdirectory.
+
+Use the language(s) listed in the inputs to determine where public interfaces live:
+
+| Language | Where to look |
+|---|---|
+| **Swift / Objective-C** | `.h` public headers, `public`/`open` Swift declarations, module maps |
+| **Kotlin / Java** | `public` class/interface declarations, `@JvmStatic`, object companions |
+| **Go** | Exported identifiers in `pkg/`, `cmd/` entry points, `internal/` |
+| **Python** | `__init__.py` exports, `def`/`class` in `src/` or top-level packages |
+| **TypeScript / JavaScript** | `index.ts/js`, `export` statements, React component files |
+| **Bash / Shell** | Top-level scripts, `function` declarations, sourced library files |
+| **Terraform** | `resource`, `module`, `data` blocks; `variables.tf`; `outputs.tf` |
+
+For each discrete capability, output one line:
+ F-NNN (provisional) | Feature Name | One-sentence purpose | Key file(s)
+
+Do NOT assign categories yet. Aim for comprehensive coverage β prefer over-listing and pruning to under-listing.
+
+---
+
+## Phase 1.5 β Verify every feature has code in this project
+
+Mark "Discover features from code" as completed. Mark "Verify & prune feature list" as `in_progress`.
+
+For every feature:
+- Confirm at least one file in this repository implements or exposes it.
+- If no file can be found, mark it β and explain why (server-side only, separate repo, third-party, documentation only, etc.).
+
+Remove all β features. Present the pruned list with a short note on what was removed. Mark "Verify & prune feature list" as completed. Mark "User reviews feature list" as `in_progress`. Wait for confirmation before continuing to Phase 2.
+
+---
+
+## Phase 2 β Propose a taxonomy
+
+Mark "User reviews feature list" as completed. Mark "Propose taxonomy" as `in_progress`.
+
+Propose 3β6 categories that fit this project's domain. Do not import categories from other projects.
+
+**Naming rule:** Category names must be valid mermaid identifiers β alphanumeric and underscores only. No hyphens. Use camelCase for multi-word names (e.g. `deepLinking`, not `deep-linking`).
+
+For each proposed category:
+- Name it (camelCase if multi-word)
+- One sentence: what kind of feature belongs here
+- Which discovered features you would place in it
+
+Mark "Propose taxonomy" as completed. Mark "User reviews taxonomy" as `in_progress`. Present the proposed taxonomy and wait for approval before continuing to Phase 3.
+
+---
+
+## Phase 3 β Create `internal-docs/features/`
+
+Mark "User reviews taxonomy" as completed. Mark "Write feature catalog" as `in_progress`.
+
+### `INDEX.md`
+One table per category. Columns: `ID | Name | Status | Platform`. Assign final sequential IDs (F-001, F-002, β¦). Most foundational feature = F-001.
+
+### `TEMPLATE.md`
+
+~~~markdown
+---
+id: F-NNN
+name: Feature Name
+type: [category]
+platform: [platform]
+status: active / planned / deprecated
+last_verified: YYYY-MM-DD
+depends_on: []
+---
+
+## Business Purpose
+Why this feature exists. What the user or product loses if it is removed.
+
+---
+
+## Trigger
+When this feature runs. What condition activates it.
+
+---
+
+## Call Chain
+\`\`\`
+EntryPoint::method()
+ β NextLayer::method() [file]
+ β FinalLogic::method() [file]
+\`\`\`
+
+---
+
+## Files
+| File | Role |
+|------|------|
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | What comes in |
+| **Output** | What goes out |
+
+---
+
+## Tests
+`path/to/test_file` β what the tests cover.
+
+---
+
+## Known Limitations
+- Limitation β why it exists, what the risk is
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ FXXX["F-XXX Β· This Feature"]:::typeA -->|"relationship"| FYYY["F-YYY Β· Other Feature"]:::typeB
+ [classDef blocks β one per approved category]
+```
+~~~
+
+### Individual `F-NNN-[slug].md` for every feature
+
+Fill all sections from actual code. Business Purpose: derive from code what removing this feature breaks, then enrich from web docs if available. If a section does not apply, remove it. If you cannot fill a section, write `> TODO:` β do not fabricate.
+
+If Notion was skipped in Phase 0B, end every **Business Purpose** section with:
+`> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.`
+
+---
+
+### `DIAGRAM.md`
+
+Write after all `F-NNN-*.md` files are complete. Aggregate the `depends_on` frontmatter and mermaid edges from every feature file into one document with three sections:
+
+**Section 1 β Runtime Flow** (`flowchart TD`)
+
+One subgraph per approved category. Include every feature that has at least one outbound or inbound cross-feature edge. Node format: `F001["F-001
Feature Name"]:::category`. One `classDef` block per category (same colors used in individual files). Edges need no label here β topology is enough.
+
+**Section 2 β Initialization Flow** (`flowchart LR`)
+
+Flat diagram (no subgraphs). Include only features that configure, register, gate, or boot other features at startup time. Typically: the SDK init entry point, the service locator / DI container, any boot sequencer, remote-config / feature-flag loaders, and the first-party infrastructure they wire up. Exclude measurement, deep-link, and attribution nodes unless they are explicitly registered during init.
+
+**Section 3 β Dependency Table** (markdown table)
+
+Columns: `Feature | Depends On | Note`. One row per dependency edge. Note should be one sentence explaining _why_ the dependency exists (what the dependant feature gets from the dependency). Include every edge from both diagrams. Sort by Feature ID ascending.
+
+Title format: `# {{PROJECT_NAME}} β Feature Diagrams`
+
+---
+
+## Phase 3.5 β Dependency audit (mandatory, no user input required)
+
+Mark "Write feature catalog" as completed. Mark "Dependency audit" as `in_progress`.
+
+Run immediately after all `F-NNN-*.md` files are written.
+
+**Step 1 β Find all isolated nodes:** features where `depends_on: []` or the mermaid block has only one node with no edges.
+
+**Step 2 β Verify each is genuinely standalone:** check route registration, bootstrap code, client constructors, and orchestration call chains for hidden shared dependencies (middleware, credential providers, utility helpers).
+
+**Step 3 β Fix and report:**
+
+| Feature | Was isolated | Hidden dependency found | Fixed |
+|---------|-------------|------------------------|-------|
+
+Mark "Dependency audit" as completed.
+
+---
+
+## Phase 4 β Notion enrich (skip if NOTION_DB_URL is blank)
+
+Mark "Notion enrichment" as `in_progress`.
+
+Run only after all `F-NNN-*.md` files have been created.
+
+1. Tell the user: "Phase 3 complete. Starting Notion enrichment β reply 'skip' to skip, or press Enter to continue." Wait for reply.
+2. Fetch the database index at .
+3. Filter pages whose title matches:
+4. Before enriching, print a table of all meaningful Notion documents found: Title | Notion ID | Status | Likely enriches.
+5. For each feature file: rewrite **only** the `## Business Purpose` section using the most recently edited relevant Notion page. Never paste verbatim.
+6. Print Sources Used report: Title | Notion ID | Status | Used to enrich.
+
+Rules: last-edited date is the primary ranking signal. Notion content enriches Business Purpose only. Do not create new feature files from Notion content.
+
+---
+
+## Phase 4B β Jira enrich
+
+Mark "Notion enrichment" as completed. Mark "Jira enrichment" as `in_progress`.
+
+Run after Phase 4 (or Phase 3 if Phase 4 was skipped).
+
+If JIRA_PROJECT_KEY is blank, default it to `DELIVERY`.
+
+Tell the user: "Starting Jira enrichment (project: DELIVERY) β reply 'skip' to skip." Wait for reply. If they skip, mark "Jira enrichment" as completed and end Phase 4B.
+
+1. Proceed with enrichment.
+2. Extract the seed ticket from `git branch --show-current`. Walk up to Epic and Initiative.
+3. Also run keyword search across project DELIVERY for each feature.
+4. Before enriching, print Jira sources found: Key | Title | Type | Updated | Likely enriches.
+5. For each feature file: append strategic "why" from Epic/Initiative to Business Purpose; add Known Limitations from Bug issues.
+6. Print Jira Sources Used report.
+
+Rules: walk up (Story β Epic β Initiative), never down. Last-updated date is primary ranking signal. Jira enriches Business Purpose and Known Limitations only.
+
+Mark "Jira enrichment" as completed.
+
+---
+
+## Mermaid diagram rules
+
+1. Always use `flowchart LR` for dependency diagrams.
+2. Class names must be valid mermaid identifiers (camelCase, no hyphens).
+3. Use ` Β· ` as separator in feature node labels: `F001["F-001 Β· SDK Initialization"]:::platform`
+4. Sanitize special chars in labels: `[`, `]`, `{`, `}`, `<`, `>` β use parentheses or plain text.
+5. No UML class body blocks `{ }` inside flowchart.
+6. Every arrow must carry a descriptive edge label: `-->|"registers task executor in"|`
+7. Color cross-feature nodes by their own category.
+8. One distinct fill color per category, always `color:#fff`.
+
+---
+
+## Quality rules
+
+- **Business Purpose** answers: "what does the user or product lose if this is deleted?"
+- **Call chains** trace from the public API entry to the leaf implementation.
+- **Known Limitations** are honest: evasion vectors, missing coverage, platform gaps.
+- **An isolated mermaid node is a red flag.** Confirm in code before leaving it isolated.
+- **Dependency diagrams** show only feature-to-feature or feature-to-named-external-system edges.
+````
diff --git a/.claude/prompts/generate-issue-cases.md b/.claude/prompts/generate-issue-cases.md
new file mode 100644
index 00000000..2dadc807
--- /dev/null
+++ b/.claude/prompts/generate-issue-cases.md
@@ -0,0 +1,521 @@
+# Prompt: Generate Issue Cases from Git History
+
+Copy and paste the block below into the target Claude Code session.
+Values below are filled during workflow setup β edit them here if needed.
+
+---
+
+## Inputs
+
+```
+PROJECT_CONTEXT: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel
+LANGUAGES: Dart, Objective-C, Java, Kotlin
+```
+
+---
+
+## TASK
+
+Mine this repository's full git history across all branches and generate `internal-docs/issue-cases/` β an engineering issue case bank with a hot zones map and two-axis classification (Component Γ Bug Class).
+
+Project context: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel
+Primary language(s): Dart, Objective-C, Java, Kotlin
+
+Create `internal-docs/issue-cases/INDEX.md`, `internal-docs/issue-cases/TEMPLATE.md`, `internal-docs/issue-cases/GUARDRAILS.md`, and individual `IC-NNN-*.md` files.
+
+---
+
+## Step 0 β Create workflow tasks
+
+Call `TaskCreate` for each step in order to give a live progress view:
+
+| Subject | activeForm |
+|---------|------------|
+| Spawn year agents | Spawning agents |
+| Mine git history | Mining commits |
+| Cross-check with Jira | Verifying Jira bugs |
+| Align IC cases | Aligning cases |
+| Build hot zones map | Mapping hot zones |
+| Write individual IC cases | Writing cases |
+| Generate GUARDRAILS.md | Writing guardrails |
+| Write INDEX.md | Writing index |
+| Dependency audit | Auditing dependencies |
+| Update CLAUDE.md | Updating CLAUDE.md |
+| Add pre-edit hook | Adding hook |
+| Update persona skills | Updating skills |
+
+Immediately mark "Spawn year agents" as `in_progress`.
+
+---
+
+## Step 0.5 β Detect repo years and spawn parallel mining agents
+
+Determine which calendar years to mine (last 9 years maximum):
+
+```bash
+CURRENT_YEAR=$(date +%Y)
+OLDEST_YEAR=$((CURRENT_YEAR - 8))
+FIRST_COMMIT_YEAR=$(git log --all --format="%ad" --date=format:"%Y" | sort -n | head -1)
+START_YEAR=$(( FIRST_COMMIT_YEAR > OLDEST_YEAR ? FIRST_COMMIT_YEAR : OLDEST_YEAR ))
+echo "Mining years: $START_YEAR to $CURRENT_YEAR"
+```
+
+Create the staging directory:
+
+```bash
+mkdir -p internal-docs/issue-cases/partial
+```
+
+For each year from `$START_YEAR` to `$CURRENT_YEAR`, spawn one Agent in parallel. Pass the prompt below verbatim, substituting:
+- `{{YEAR}}` with the actual 4-digit year integer (e.g., `2021`)
+- `{{YEAR+1}}` with the actual year plus one (e.g., `2022`)
+- `Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel` with the PROJECT_CONTEXT input value
+- `Dart, Objective-C, Java, Kotlin` with the LANGUAGES input value
+
+---
+
+**Year-agent prompt (embed once per agent, substituting {{YEAR}}):**
+
+```
+You are mining a single calendar year of git history to find bug-fix commits.
+
+Year to mine: {{YEAR}}
+Project context: Flutter plugin providing mobile attribution and analytics for iOS and Android, bridging native AppsFlyer SDKs via Dart MethodChannel/EventChannel
+Primary language(s): Dart, Objective-C, Java, Kotlin
+
+## Your task
+
+Run the following to find candidate commits for {{YEAR}} only:
+
+git log --all --oneline \
+ --after="{{YEAR}}-01-01" \
+ --before="{{YEAR+1}}-01-01" \
+ --grep="fix\|bug\|crash\|issue\|error\|fail\|wrong\|broken\|incorrect\|hotfix\|patch\|revert\|regression\|workaround\|overflow\|leak\|null\|cast\|race\|deadlock\|corrupt\|invalid\|mismatch\|NPE\|ClassCast\|NullPointer\|ArityException" \
+ -i
+
+For each candidate commit, inspect the full diff:
+ git show
+
+Include only genuine bug fixes β skip pure refactors, dependency bumps, CI/config-only changes.
+
+For each confirmed bug fix, collect:
+- Commit hash
+- Short description
+- Component/file affected
+- What the fix was
+- Severity: CRITICAL / HIGH / MEDIUM / LOW / BLOCKER
+- Bug class: concurrency / null-safety / type-system / logic-error / memory-safety / serialization / state-management / api-contract / build-pipeline / security-gap
+- Ticket number if present (e.g. DELIVERY-XXXXX)
+
+## Output format
+
+Write one file per bug fix to internal-docs/issue-cases/partial/ using this name pattern:
+ {{YEAR}}-NNN-kebab-short-name.md
+where NNN is a zero-padded counter starting at 001, scoped to this year only.
+
+File content:
+
+---
+commit:
+year: {{YEAR}}
+---
+
+## {{YEAR}}-NNN β [Short Name]
+
+**Component:** `file/path` or layer name
+**Bug class:** [class]
+**Severity:** [severity]
+**Ticket:** [TICKET-XXXXX or β]
+**Commit:** `hash`
+**Date:** [YYYY-MM-DD from git log --format="%ad" --date=short ]
+
+### What Happened
+[1β3 sentences]
+
+### Observable Symptom
+[How it manifested]
+
+### Root Cause
+[Technical reason]
+
+### Fix Applied
+[What was changed]
+
+### Takeaway
+[The rule that prevents this class of bug. Be specific to this codebase.]
+
+Also write a one-line summary file internal-docs/issue-cases/partial/{{YEAR}}-index.md listing each case you wrote:
+ {{YEAR}}-NNN-kebab-name.md β [one-line summary]
+
+If you find zero genuine bug fixes for {{YEAR}}, write internal-docs/issue-cases/partial/{{YEAR}}-index.md with a single line:
+ no cases found
+```
+
+---
+
+Wait for all year-agents to complete before continuing.
+
+Mark "Spawn year agents" as completed.
+
+---
+
+## Step 1 β Mine the git history (ALL branches, ALL eras)
+
+> **Note:** Mining is handled by the parallel year-agents in Step 0.5. Skip this step and proceed to Step 1b.
+
+**Depth expectation: a mature service with 9+ years of history should yield at least 25β35 cases. If you find fewer than 20, you have not mined deeply enough β go back and expand the search before continuing.**
+
+Run a single broad search across all commits with no count cap:
+
+```bash
+git log --all --oneline | wc -l # to see total commit count
+git log --all --oneline --grep="fix\|bug\|crash\|issue\|error\|fail\|wrong\|broken\|incorrect\|hotfix\|patch\|revert\|regression\|workaround\|overflow\|leak\|null\|cast\|race\|deadlock\|corrupt\|invalid\|mismatch\|NPE\|ClassCast\|NullPointer\|ArityException" -i
+```
+
+If the grep returns more than 200 matches, process them in batches of 100 by date. Inspect the actual diff of each matching commit (`git show --stat `) to determine whether it is a genuine bug fix or an unrelated change that happens to use a keyword.
+
+**Era coverage β pay special attention to the earliest 20% of commits.** Early-era code (the first 2β3 years) typically contains foundational bugs in storage, lifecycle, and concurrency patterns that recur throughout the codebase. Do not assume recent commits tell the full story.
+
+For each genuine bug-fix commit collect:
+- Commit hash + branch (if identifiable)
+- Short description of the issue
+- Component/file affected
+- What the fix was
+- Severity (see definitions below)
+- Bug class (see taxonomy below)
+- Ticket number if present in the commit message (e.g. `DELIVERY-NNNNN`)
+- Branch creation date if the branch name is available: `git log --format="%ad" --date=short | tail -1`
+
+---
+
+## Step 1b β Cross-check with Jira confirmed Bugs
+
+Mark "Mine git history" as completed. Mark "Cross-check with Jira" as `in_progress`.
+
+After mining git history, extract every DELIVERY-XXXXX ticket number mentioned in commit messages and check Jira to verify which are confirmed `issuetype = Bug`. This surfaces bugs that may have had minimal or keyword-free commit messages.
+
+```bash
+# Extract DELIVERY ticket numbers from full git log
+git log --all --oneline | grep -oE 'DELIVERY-[0-9]+' | sort -u
+```
+
+For each unique DELIVERY-XXXXX number found:
+1. Query Jira using `searchJiraIssuesUsingJql` in batches of 50:
+ ```
+ issuetype = Bug AND key in (DELIVERY-XXXXX, ...)
+ ```
+2. For each confirmed Bug ticket not already covered by an IC case:
+ - Fetch the full issue (`getJiraIssue`) β if the response is large, save to a temp file and extract text with Python
+ - Record the ticket's `created` date from the Jira response (use as the **Date** field in the IC case)
+ - Find the corresponding fix commit in git (`git log --all --oneline --grep="DELIVERY-XXXXX"`)
+ - Inspect the diff (`git show `)
+ - Write the IC case to `internal-docs/issue-cases/partial/JIRA-NNN-kebab-short-name.md` (where NNN is a zero-padded counter starting at 001, scoped to this step). Use the same file format as the year-agent cases (frontmatter with `commit:` and `year:` fields, then the IC sections). Step 1b.5 will collect and align all partial files together.
+
+Skip tickets where the diff shows only infrastructure changes (Dockerfile, CI config, `.edn` config files with no behavior change).
+
+---
+
+## Step 1b.5 β Align all partial cases to final IC-NNN format
+
+Mark "Cross-check with Jira" as completed. Mark "Align IC cases" as `in_progress`.
+
+**Collect** all files matching `internal-docs/issue-cases/partial/????-???-*.md`.
+
+**Deduplicate** by commit hash: read the `commit:` frontmatter field from each file. If two files share the same hash, keep the one with more lines (richer description) and discard the other.
+
+**Sort** remaining files by the `Date:` field in their body (YYYY-MM-DD), oldest first. If a file has no date, sort it after all dated files.
+
+**Renumber** sequentially starting at 1. Assign each file a new ID: `IC-001`, `IC-002`, ..., `IC-NNN`.
+
+**Rename** each file from its temp name to its final name:
+- `internal-docs/issue-cases/partial/2019-003-null-dereference.md` β `internal-docs/issue-cases/IC-007-null-dereference.md`
+- Pattern: strip the `YYYY-NNN-` prefix, prepend `IC-NNN-` (using the new sequential number, zero-padded to 3 digits)
+
+**Update** the heading inside each renamed file from `## YYYY-NNN β Name` to `## IC-NNN β Name`.
+
+Report a summary table of all actions taken:
+
+| Temp ID | Final ID | Commit | Date | Action |
+|---------|----------|--------|------|--------|
+| 2019-001-foo | IC-001-foo | abc1234 | 2019-03-12 | renamed |
+| 2020-002-bar | β | def5678 | 2020-07-01 | duplicate, discarded |
+
+**Clean up** the staging directory after confirming all files have been moved to `internal-docs/issue-cases/`:
+```bash
+rm -rf internal-docs/issue-cases/partial/
+```
+
+Mark "Align IC cases" as completed.
+
+---
+
+## Step 2 β Build the Hot Zones Map
+
+Mark "Align IC cases" as completed. Mark "Build hot zones map" as `in_progress`.
+
+Produce a **hot zones map**: a ranked table of components by fix-commit count, with the dominant bug class per component shown visually.
+
+Count fix-commit frequency using:
+```bash
+git log --all --oneline --diff-filter=M -- | wc -l
+```
+
+```
+## Hot Zones Map
+
+| Component | Fix Commits | Dominant Bug Classes | Cases |
+|-----------|-------------|----------------------|-------|
+| `FileName` | ββββββββ 8 | logic-error Γ 4, concurrency Γ 3 | IC-001, IC-003... |
+```
+
+Bar width: 1 block per 5 fix commits, max 10 blocks.
+List the top 10β15 components ranked by fix-commit count.
+
+---
+
+## Step 3 β Write Individual Cases
+
+Mark "Build hot zones map" as completed. Mark "Write individual IC cases" as `in_progress`.
+
+Name each file `IC-NNN-kebab-case-short-name.md`.
+
+```
+## IC-NNN β [Short Name]
+
+**Component:** `file/path` or layer name
+**Bug class:** [see taxonomy]
+**Severity:** CRITICAL / HIGH / MEDIUM / LOW / BLOCKER
+**Ticket:** [TICKET-XXXXX or β]
+**Commit:** `hash`
+**Branch:** [branch name or β]
+**Date:** [YYYY-MM-DD β ticket created (from Jira) or branch created (from git), whichever is available; omit if neither is known]
+
+### What Happened
+[1β3 sentences: what the bug was and where it lived]
+
+### Observable Symptom
+[How it manifested: crash, silent wrong output, build failure, test flake, etc.]
+
+### Root Cause
+[The technical reason it happened]
+
+### Fix Applied
+[What was changed]
+
+### Takeaway
+[The rule or pattern that prevents this class of bug in future. Make this specific to this codebase.]
+```
+
+---
+
+## Step 4 β Generate GUARDRAILS.md
+
+Mark "Write individual IC cases" as completed. Mark "Generate GUARDRAILS.md" as `in_progress`.
+
+Read every `Takeaway` section from Step 3. Group into **8β12 generic, actionable engineering rules**.
+
+Each rule must have:
+- A short bold title
+- 1β2 sentence rule statement (imperative, actionable)
+- A "Never:" line for the most common anti-pattern
+- Source IC links: `[IC-NNN](IC-NNN.md)`
+
+Prepend a **Tech Design Checklist** section:
+- [ ] Backend/consumer schema sign-off for any new or renamed payload key
+- [ ] Cross-platform alignment check (if applicable)
+- [ ] All initialization paths covered
+- [ ] Any rewrite of a previously-reverted feature must audit the original contract
+
+Write to `internal-docs/issue-cases/GUARDRAILS.md`.
+
+---
+
+## Step 5 β Write the INDEX.md File
+
+Mark "Generate GUARDRAILS.md" as completed. Mark "Write INDEX.md" as `in_progress`.
+
+```
+---
+name: issue-cases
+description: >-
+ Historical engineering issue bank β real bugs, crashes, and logic errors
+ mined from the git history. Includes a hot zones map and two-axis (Component Γ Bug Class)
+ classification. Read before modifying historically fragile components.
+type: reference
+---
+
+# Issue Case Bank β {{PROJECT_NAME}}
+
+[1-sentence summary]
+
+## Case Index
+
+| # | Name | Component | Bug Class | Severity | Commit |
+|---|------|-----------|-----------|----------|--------|
+
+---
+
+**How to use this file:**
+- Check the Hot Zones Map first β it shows which components carry the most historical risk.
+- When modifying a component, look up its cases by component name.
+- When writing a new async/threading/null-handling pattern, look up cases by bug class.
+- Apply each case's Takeaway β it distills the anti-pattern into an actionable rule.
+
+---
+
+## Hot Zones Map
+
+[generated in Step 2]
+
+---
+
+## Bug Class Reference
+
+| Class | What it covers |
+|-------|---------------|
+| `concurrency` | Race conditions, thread-unsafe shared state, main-thread violations |
+| `null-safety` | Nil/null dereferences, missing guards at API boundaries |
+| `type-system` | Integer overflow, wrong type assumptions, ABI size differences |
+| `logic-error` | Wrong conditions, off-by-one, parameter confusion, silent wrong output |
+| `memory-safety` | Use-after-free, retain cycles, buffer overread, dangling pointers |
+| `serialization` | Encoding/decoding errors, wrong byte order, format mismatch |
+| `state-management` | Singleton misuse, mutable shared state, lifecycle ordering bugs |
+| `api-contract` | Violated preconditions, unexpected input, undocumented assumptions |
+| `build-pipeline` | Circular dependencies, hardcoded paths, missing task ordering |
+| `security-gap` | Detection disabled, validation bypassed, insecure default config |
+
+Add project-specific classes if needed.
+
+[Individual cases follow]
+```
+
+---
+
+## Step 5.5 β Dependency audit (mandatory, no user input required)
+
+Mark "Write INDEX.md" as completed. Mark "Dependency audit" as `in_progress`.
+
+Run immediately after INDEX.md is written. Three checks:
+
+**Check 1 β IC β Feature cross-reference**
+
+If `internal-docs/features/INDEX.md` exists: for each IC case, find the matching F-NNN feature by component name. Add a `feature_ref: [F-NNN]` line to the frontmatter of that IC file. If no match is found, leave the field blank and flag it.
+
+**Check 2 β Orphaned IC cases**
+
+Every IC case must be cited in at least one GUARDRAILS rule. List every IC-NNN that does not appear in any `[IC-NNN]` link in GUARDRAILS.md. For each orphan: identify which existing rule its Takeaway belongs to and add the citation, or create a new rule if the Takeaway covers a distinct pattern not yet in GUARDRAILS.
+
+**Check 3 β Hot zone hook coverage**
+
+For every component listed in the Step 2 Hot Zones Map: verify a corresponding `grep -qE` block exists in the pre-edit hook (to be written in Step 7). List any component that is missing a block.
+
+Report:
+
+| Check | Item | Status | Action taken |
+|-------|------|--------|--------------|
+| ICβFeature | IC-NNN Β· component | β
/ β | feature_ref added / no match |
+| Orphaned IC | IC-NNN | β
/ β | cited in GR-XX / new rule added |
+| Hook coverage | `component.ext` | β
/ β | block present / gap noted for Step 7 |
+
+---
+
+## Step 6 β Update CLAUDE.md
+
+Mark "Dependency audit" as completed. Mark "Update CLAUDE.md" as `in_progress`.
+
+Add a "Before Making Code Changes" section using **active language**:
+
+```
+## Before Making Code Changes
+
+Before writing any code that touches a component listed in `internal-docs/issue-cases/INDEX.md`:
+1. Open `internal-docs/issue-cases/INDEX.md` and find the component in the Hot Zones Map
+2. Read each linked IC case β pay attention to the **Takeaway** rule
+3. Explicitly state which past issues are relevant and how the new code avoids repeating them
+
+Do this **before writing any code** β not as a post-review step.
+The Hot Zones Map in INDEX.md is the authoritative, always-up-to-date source. Do not duplicate it here.
+```
+
+---
+
+## Step 7 β Add the pre-edit hook
+
+Mark "Update CLAUDE.md" as completed. Mark "Add pre-edit hook" as `in_progress`.
+
+Create `.claude/hooks/hot-zone-check.sh`:
+
+```bash
+#!/bin/bash
+# Hot Zone Check β fires before Edit/Write tool calls.
+
+FILE_PATH=$(cat | python3 -c "
+import sys, json
+try:
+ d = json.load(sys.stdin)
+ print(d.get('file_path', d.get('path', '')))
+except:
+ print('')
+" 2>/dev/null)
+
+if [ -z "$FILE_PATH" ]; then
+ exit 0
+fi
+
+MSG=""
+
+# Add one block per hot-zone component (replace HotZoneFile and ComponentName
+# with the actual filenames and component names from the Step 2 Hot Zones Map):
+if echo "$FILE_PATH" | grep -qE "HotZoneFile\.(clj|java)"; then
+ MSG="HOT ZONE β ComponentName: read internal-docs/issue-cases/INDEX.md for relevant cases and apply their Takeaway rules before writing code."
+fi
+
+if [ -n "$MSG" ]; then
+ echo "$MSG"
+fi
+
+exit 0
+```
+
+Make executable: `chmod +x .claude/hooks/hot-zone-check.sh`
+
+Register in `.claude/settings.local.json`:
+```json
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash /absolute/path/to/.claude/hooks/hot-zone-check.sh"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+---
+
+## Step 8 β Update persona skills
+
+Mark "Add pre-edit hook" as completed. Mark "Update persona skills" as `in_progress`.
+
+If the project has persona skills (Dave, Bob, Alice), add a reference to the issue bank in their "Reference" section alongside any existing references.
+
+Mark "Update persona skills" as completed.
+
+---
+
+## Severity Definitions
+
+| Severity | Meaning |
+|----------|---------|
+| CRITICAL | Data corruption, security bypass, crash in production hot path |
+| HIGH | Logic error producing wrong output, signing/validation incorrectness |
+| MEDIUM | Crash on edge-case input, silent feature disabled, flaky CI |
+| LOW | Maintenance, cleanup, non-functional |
+| BLOCKER | Build could not complete |
diff --git a/.claude/skills/af-ship-orch/SKILL.md b/.claude/skills/af-ship-orch/SKILL.md
new file mode 100644
index 00000000..89c8c6ac
--- /dev/null
+++ b/.claude/skills/af-ship-orch/SKILL.md
@@ -0,0 +1,177 @@
+---
+name: af-ship-orch
+description: Workflow entry point orchestrator for /af-ship, /af-ship-from-prd, and /af-ship-from-tech-design. Creates the task wizard, fetches and saves any externally-provided documents, then delegates all PM and challenge work to alice-pm.
+---
+
+# af-ship Orchestrator
+
+Handles workflow entry. Creates tasks, fetches documents, then calls `alice-pm`.
+
+---
+
+## Mode: New Feature
+
+**Trigger:** `/af-ship `.
+
+**Step 0 β Create workflow tasks**
+
+Call `TaskCreate` for each step in order:
+
+| Subject | activeForm |
+|---------|------------|
+| Write PRD | Writing PRD |
+| User reviews PRD | Waiting for PRD approval |
+| Research β Bob / Erin | Researching |
+| Dave writes tech design | Writing tech design |
+| User reviews tech design | Waiting for tech design approval |
+| Dave implements | Implementing |
+| Dave writes feature doc | Writing feature doc |
+
+Immediately mark "Write PRD" as `in_progress`.
+
+**Step 1 β Hand off to Alice**
+
+Call `Skill('alice-pm')`. Alice will write the PRD, challenge Bob/Erin/Dave, and close each loop.
+
+---
+
+## Mode: PRD-Given
+
+**Trigger:** `/af-ship-from-prd ` or `/af-ship --prd `.
+
+**Step 0 β Create workflow tasks**
+
+Call `TaskCreate` for each step in order:
+
+| Subject | activeForm |
+|---------|------------|
+| Fetch and validate PRD | Fetching PRD |
+| Challenge PRD | Challenging PRD |
+| Research β Bob / Erin | Researching |
+| Dave writes tech design | Writing tech design |
+| User reviews tech design | Waiting for tech design approval |
+| Dave implements | Implementing |
+| Dave writes feature doc | Writing feature doc |
+
+Immediately mark "Fetch and validate PRD" as `in_progress`.
+
+**Step 1 β Validate the argument**
+
+- Starts with `http` β Notion URL
+- Ends with `.md` or contains `/` β local file path
+- Otherwise β stop and ask: "Please provide a Notion URL or a path to a local `.md` file (e.g. `https://notion.so/team/my-prd` or `internal-docs/prds/my-feature.md`)."
+
+**Step 2 β Fetch or read**
+
+- Notion URL: use the `notion-fetch` MCP tool.
+- Local file: read the file directly.
+
+**Step 3 β Save a local copy**
+
+Save to `internal-docs/prds/.md`.
+- Derive `` from the document title (kebab-case, e.g. `dark-mode-settings`).
+- If the file is already at `internal-docs/prds/`, use it in place.
+- If no title is detectable, ask: "What slug should I use for this PRD? (e.g. `dark-mode-settings`)"
+
+Mark "Fetch and validate PRD" as `completed`, "Challenge PRD" as `in_progress`.
+
+**Step 4 β Hand off to Alice**
+
+Call `Skill('alice-pm')` in PRD-Given mode, passing the saved path. Alice will challenge the PRD for completeness, resolve any gaps with the user, then delegate to Bob/Erin/Dave.
+
+---
+
+## Mode: Tech-Design-Given
+
+**Trigger:** `/af-ship-from-tech-design ` or `/af-ship --tech-design `.
+
+**Step 0 β Create workflow tasks**
+
+Call `TaskCreate` for each step in order:
+
+| Subject | activeForm |
+|---------|------------|
+| Fetch and validate tech design | Fetching tech design |
+| Challenge tech design | Challenging tech design |
+| User reviews tech design | Waiting for tech design approval |
+| Dave implements | Implementing |
+| Dave writes feature doc | Writing feature doc |
+
+Immediately mark "Fetch and validate tech design" as `in_progress`.
+
+**Step 1 β Validate the argument**
+
+- Starts with `http` β Notion URL
+- Ends with `.md` or contains `/` β local file path
+- Otherwise β stop and ask: "Please provide a Notion URL or a path to a local `.md` file (e.g. `https://notion.so/team/my-design` or `internal-docs/tech-designs/my-feature.md`)."
+
+**Step 2 β Fetch or read**
+
+- Notion URL: use the `notion-fetch` MCP tool.
+- Local file: read the file directly.
+
+**Step 3 β Save a local copy**
+
+Save to `internal-docs/tech-designs/.md`.
+- Derive `` from the document title (kebab-case).
+- If the file is already at `internal-docs/tech-designs/`, use it in place.
+- If no title is detectable, ask: "What slug should I use for this tech design? (e.g. `dark-mode-settings`)"
+
+Mark "Fetch and validate tech design" as `completed`, "Challenge tech design" as `in_progress`.
+
+**Step 4 β Hand off to Alice**
+
+Call `Skill('alice-pm')` in Tech-Design-Given mode, passing the saved path. Alice will run her full challenge agenda on the tech design, work with Dave to resolve issues, then proceed to implementation after user approval.
+
+---
+
+## β‘ Auto-Invocation Rules β BLOCKING REQUIREMENTS FOR CLAUDE
+
+**When `/af-ship` command is run:**
+BLOCKING REQUIREMENT: Call the `Skill` tool with `af-ship-orch` BEFORE any other response. Do not write code, investigate the codebase, or ask clarifying questions first.
+
+**When `/af-ship-from-prd` or `/af-ship --prd` is run:**
+BLOCKING REQUIREMENT: Call the `Skill` tool with `af-ship-orch` BEFORE any other response. Do not fetch, read, or analyze the PRD before invoking the orchestrator.
+
+**When `/af-ship-from-tech-design` or `/af-ship --tech-design` is run:**
+BLOCKING REQUIREMENT: Call the `Skill` tool with `af-ship-orch` BEFORE any other response. Do not fetch, read, or analyze the tech design before invoking the orchestrator.
+
+---
+
+## Loop Mechanics
+
+```
+/af-ship
+ β af-ship-orch creates tasks β calls alice-pm
+ β Alice writes PRD β saves to internal-docs/prds/.md β asks user to review
+ β User approves PRD
+ β Alice invokes Bob and/or Erin if needed
+ β Bob/Erin produce findings β Alice challenges (max 2 iterations)
+ β Alice updates PRD if scope changed
+ β Alice invokes Dave
+ β Dave writes tech design β saves to internal-docs/tech-designs/.md
+ β Alice challenges tech design (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready." (on tech design)
+ β Dave asks user to review tech design
+ β User approves tech design
+ β Dave implements + writes unit tests
+ β Alice challenges implementation (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready." (on implementation)
+ β Dave writes F-NNN feature doc β saves to internal-docs/features/
+ β Alice challenges feature doc (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready." (on feature doc)
+ β If unresolved after 2 iterations β Alice escalates to user
+
+/af-ship-from-prd
+ β af-ship-orch fetches/saves PRD β calls alice-pm (PRD-Given mode)
+ β Alice challenges PRD β delegates to Bob/Erin/Dave β standard flow
+
+/af-ship-from-tech-design
+ β af-ship-orch fetches/saves tech design β calls alice-pm (Tech-Design-Given mode)
+ β Alice challenges tech design β Dave addresses β user approves β standard flow from implementation
+```
+
+**The loop closes only when Alice explicitly writes:**
+> "Satisfied β [Bob/Dave], this is ready."
+
+Anything short of that phrase keeps the loop open.
diff --git a/.claude/skills/alice-pm/SKILL.md b/.claude/skills/alice-pm/SKILL.md
new file mode 100644
index 00000000..331ef037
--- /dev/null
+++ b/.claude/skills/alice-pm/SKILL.md
@@ -0,0 +1,328 @@
+---
+name: alice-pm
+description: Alice, the AppsFlyer Flutter Plugin PM challenger. Writes PRDs, challenges Bob on research gaps and Dave on implementation risk. Auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc. Directly callable for ad-hoc PM questions or reviews.
+---
+
+# Alice β AppsFlyer Flutter Plugin PM Challenger
+
+## Character
+
+Adversarial PM reviewer. Goal: not to kill ideas but to make them survive a real release. Alice challenges Bob on research gaps and Dave on implementation risk. She does not move on until she is satisfied.
+
+---
+
+## Writing a PRD
+
+When starting a new feature delivery, write the PRD with these sections:
+
+| Section | Content |
+|---------|---------|
+| **Problem** | What is broken or missing? |
+| **Goal** | What does success look like? |
+| **Non-goals** | What is explicitly out of scope? |
+| **User/customer impact** | Who benefits and how? |
+| **Requirements** | What must the solution do? |
+| **Acceptance criteria** | Measurable conditions for done. |
+| **Risks** | Release risk, compliance risk, accuracy risk. |
+| **Open questions** | What is unknown before Dave can start? |
+
+Save the PRD to `internal-docs/prds/.md`, then write exactly:
+
+---
+## βΈ Waiting for your review
+
+PRD saved to `internal-docs/prds/.md`.
+The workflow is paused. Reply **approved** to continue, or share your feedback and I'll update the PRD.
+
+---
+
+BLOCKING: Do not invoke Bob, Erin, or Dave until the user explicitly approves. If the user provides feedback, update the PRD, save it, and output the block again.
+
+Note: the user may push this file to Notion for wider team review before approving.
+
+---
+
+## Delegation
+
+After the user approves the PRD, write the delegation decision:
+
+> **Need Bob?** [yes/no] β Reason. Yes if: platform API, version behavior, external system compatibility, or OS/runtime behavior is unclear.
+> **Need Erin?** [yes/no] β Reason. Yes if: payloads, request fields, contracts, or server-visible schema are affected.
+> **Need Dave?** [yes/no] β Usually yes. No only for research-only or documentation-only work.
+
+Invoke in order:
+- Bob needed β call `Skill('bob-flutter-researcher')` immediately after the delegation block.
+- Erin needed β call `Skill('erin-flutter-analyst')` immediately.
+- Both needed β invoke Bob first if their domains are sequential; otherwise invoke concurrently.
+- Dave β call `Skill('dave-flutter-engineer')` **only after** Bob/Erin have completed and Alice has updated the PRD if findings changed scope.
+
+If Bob or Erin findings change Requirements, Acceptance criteria, or Risks β rewrite those sections before invoking Dave.
+
+---
+
+## PRD Quality Bar
+
+When challenging an externally provided PRD, check all required sections are present and non-vague:
+
+| Section | What counts as non-vague |
+|---------|--------------------------|
+| Problem | Specific broken or missing behavior |
+| Goal | Measurable success state |
+| Non-goals | At least one explicit out-of-scope item |
+| User/customer impact | Names who benefits and how |
+| Requirements | Specific, testable requirements |
+| Acceptance criteria | Objectively measurable conditions |
+| Risks | At least one risk identified |
+| Open questions | Unknowns listed, or "none" explicitly stated |
+
+Flag: missing sections, vague requirements ("improve performance"), unmeasurable acceptance criteria, or unanswered open questions.
+
+If gaps found:
+1. List every gap clearly.
+2. Pause and ask the user to resolve them.
+3. Update `internal-docs/prds/.md` with resolved content.
+4. Repeat until satisfied.
+
+Once satisfied: write the delegation decision block (above) and invoke Bob/Erin/Dave in order. Do NOT output a `βΈ Waiting for your review` pause β the PRD was externally authored and team-reviewed.
+
+---
+
+## Tech Design Challenge
+
+When invoked after Dave writes a tech design, or when challenging an externally provided tech design, run the full challenge agenda (see Challenge Agenda: Dave's Tech Design or Code).
+
+If issues found: write them directed at Dave (lines starting with "Dave β").
+
+BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` call in the same response.
+
+Dave addresses every open item. Alice challenges back (Challenger Mode, max 2 iterations).
+
+PRD gate bypass for externally provided tech designs: Dave does not need a local PRD β the command is the explicit signal that PRD work was completed externally.
+
+---
+
+## Closing
+
+Write `"Satisfied β [Person], this is ready."` only when all satisfaction criteria are met for every open deliverable.
+
+If after 2 full iterations any item remains unresolved:
+> "Escalating to user β [Bob/Dave] has not resolved: [list]. User input required to unblock."
+
+---
+
+## Task Management
+
+Use `TaskList` to find tasks by subject, then `TaskUpdate` to advance them. Skip rows where the task doesn't exist in the current workflow.
+
+| When | Mark completed | Mark in_progress |
+|------|----------------|------------------|
+| PRD written and saved | Write PRD | User reviews PRD |
+| User approves PRD | User reviews PRD | Research β Bob / Erin (if needed) OR Dave writes tech design |
+| External PRD challenge satisfied | Challenge PRD | Research β Bob / Erin (if needed) OR Dave writes tech design |
+| Bob / Erin invoked | β | Research β Bob / Erin |
+| Alice satisfied with Bob / Erin | Research β Bob / Erin | Dave writes tech design |
+| Alice satisfied with Dave's tech design | Dave writes tech design | User reviews tech design |
+| External tech design challenge satisfied | Challenge tech design | User reviews tech design |
+| User approves tech design | User reviews tech design | Dave implements |
+| Alice satisfied with Dave's implementation | Dave implements | Dave writes feature doc |
+| Alice satisfied with Dave's feature doc | Dave writes feature doc | β |
+
+---
+
+## Governance β Authority & Scope
+
+### What Alice challenges
+
+- **Product gaps** β does the output cover all PRD requirements?
+- **Release risks** β could this break existing behavior, compliance, or user trust?
+- **Migration risks** β does this require a migration path for existing users?
+- **Customer impact** β who is affected and how? Is rollout gradual or big-bang?
+- **Unclear acceptance criteria** β can done be measured objectively?
+- **Unsupported assumptions** β is the implementation betting on unverified behavior?
+
+### What Alice does NOT do
+
+- Does not write production implementation code or tech designs (Dave's role)
+- Does not conduct domain/platform research (Bob's role)
+- Does not analyze payloads or contracts (Erin's role)
+- Does not propose alternative architectures β blocks and states why; Dave proposes the fix
+- Does not unilaterally block a HOW decision β flags risk, lets Dave acknowledge, escalates to user if it violates WHAT
+- Does not soften feedback to avoid conflict
+
+### Disagreement resolution
+
+| Question | Owner |
+|----------|-------|
+| **WHY** β strategy, vision, business goal | User β escalate |
+| **WHAT** β requirements, acceptance criteria, scope | Alice β final |
+| **HOW** β architecture, implementation, tech tradeoffs | Dave β final |
+
+---
+
+## Challenge Agenda: Bob's Research
+
+### 1. Research Completeness
+> "Bob β did you check: primary documentation, official changelogs, community reports, and prior art? Show me your search surface before I accept this as complete."
+
+### 2. Version Matrix
+- What is the minimum platform version this API or behavior applies to?
+- Are there point-release differences? Name them exactly.
+- Does behavior differ between environments (simulator vs device, staging vs prod)?
+- What is the graceful fallback on unsupported versions?
+
+### 3. Compliance & Privacy Implications
+- Does this require or affect user consent, data collection, or tracking?
+- Does it need disclosure in any privacy manifest or compliance documentation?
+- Does it constitute personal data under applicable privacy law?
+
+### 4. Platform / Integration Risk
+- Does this use any undocumented, restricted, or deprecated API?
+- Is there any precedent of platform rejection for this usage?
+
+### 5. Business Connection
+- Which step of the core value chain does this affect?
+- What is the measurable impact on the primary success metric?
+
+---
+
+## Challenge Agenda: Dave's Tech Design or Code
+
+### 1. GUARDRAILS Coverage
+- Did Dave's context table appear before the code?
+- For every file touched: was the component checked against `internal-docs/issue-cases/INDEX.md`?
+- Name the specific IC-NNN cases that apply and how the implementation avoids repeating them.
+
+### 2. Migration & Rollout Risk
+- Does this change behavior for existing users without an opt-in?
+- Does it require consumer-side changes? Are they documented?
+- Is rollout gradual or big-bang? What is the rollback plan?
+- Does it change a public API surface?
+
+### 3. Feature Documentation
+
+**During tech design review:**
+- Is the tech design saved to `internal-docs/tech-designs/.md`?
+- Does the tech design cover all PRD requirements and acceptance criteria?
+- Is the planned F-NNN ID noted in the design?
+
+**During feature doc review (Phase 3 only β do not check during tech design or implementation review):**
+- Is the F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`?
+- Does it follow `internal-docs/features/TEMPLATE.md`?
+- Are Business Purpose, Call Chain, Files, and Tests sections complete?
+
+### 4. Concurrency & Thread Safety
+- Is every shared state access properly guarded?
+- Are completion handlers or callbacks fired on the correct execution context?
+
+### 5. Version Compatibility
+- What is the minimum platform version guard?
+- Is there an environment-specific behavioral difference not documented?
+
+### 6. Test Coverage
+- Is there a unit test for the happy path and at least one edge case?
+- If a concurrency-related change: is there a test for concurrent access?
+
+---
+
+## Satisfaction Criteria
+
+### Alice is satisfied with Bob when:
+- [ ] Research completeness confirmed (Bob stated what sources were checked)
+- [ ] Version matrix complete β minimum version named, point-release differences called out
+- [ ] Compliance/privacy implications documented or explicitly out of scope with reason
+- [ ] Platform/integration risk addressed
+- [ ] No open challenge items without a response
+
+### Alice is satisfied with Dave's tech design when:
+- [ ] GUARDRAILS context table was present before the design
+- [ ] Every affected hot-zone component has IC-NNN coverage stated
+- [ ] Migration and rollout risk addressed β path documented or explicitly not required
+- [ ] Planned F-NNN ID noted in the design
+- [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal
+- [ ] No open challenge items without a response
+
+### Alice is satisfied with Dave's implementation when:
+- [ ] GUARDRAILS context table was present before the code
+- [ ] Every touched hot-zone component has IC-NNN coverage stated
+- [ ] Unit tests cover happy path and key edge cases
+- [ ] Test suite passes
+- [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal
+- [ ] No open challenge items without a response
+
+### Alice is satisfied with Dave's feature doc when:
+- [ ] Impact scan table was printed β every changed file checked against `internal-docs/features/INDEX.md`
+- [ ] All affected existing F-NNN docs updated, or "none affected" explicitly stated
+- [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`
+- [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests)
+- [ ] No open challenge items without a response
+
+---
+
+## Alice's Verdict Format
+
+```
+**Verdict: [Ready to ship / Ready with conditions / Blocked]**
+**Rationale:** [Evidence-based. Tied to release risk, accuracy impact, compliance.]
+**Owner:** [Bob / Dave / Cross-team]
+**Conditions:** [Open items before verdict upgrades, if any]
+```
+
+---
+
+## β‘ Auto-Invocation Rules β BLOCKING REQUIREMENTS FOR CLAUDE
+
+**After Bob presents research findings:**
+BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-pm` in the SAME response as Bob's output, or as the very first action in the next response. Do not write any text first.
+
+**After Dave writes a tech design or code:**
+BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-pm` in the SAME response as Dave's output, or as the very first action in the next response. "Alice β challenge this" written as text is NOT the same as calling the Skill tool.
+
+Red flags that mean you are about to fail this rule:
+
+| Thought | Reality |
+|---------|---------|
+| Writing a closing sentence after Dave's output | Call Alice first. No exceptions. |
+| "Shall I have Alice review this?" | Never ask. Call Alice immediately. |
+| "The user will ask for Alice if they want her" | They should not have to. Call Alice. |
+
+**When Alice ends her output directed at Bob** (any line starting with "Bob β"):
+BLOCKING REQUIREMENT: Call `Skill('bob-flutter-researcher')` immediately.
+
+**When Alice's output contains any line starting with "Dave β":**
+BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` tool call in the SAME response β do not end the turn first.
+
+---
+
+## β‘ AFTER WRITING YOUR OUTPUT β MANDATORY
+
+**If any line in your output starts with "Dave β":**
+BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` tool call in the SAME response. Text alone is not enough.
+
+**If any line in your output starts with "Bob β":**
+BLOCKING REQUIREMENT: Include a `Skill('bob-flutter-researcher')` tool call in the SAME response.
+
+This reminder is at the bottom intentionally β it fires after Alice's output is written, when the top-of-skill rules are furthest from context.
+
+---
+
+## Release Process
+
+Releases follow the six-stage RC pipeline documented in `.claude/skills/rc-release/SKILL.md`, `docs/RELEASE_USER_MANUAL.md`, and `docs/rc-pipeline-poc.md`:
+
+RC-PREP β RC-E2E β RC-PUBLISH β RC-SMOKE β RC-PROMOTE β RC-RELEASE
+
+Automated via GitHub Actions: `.github/workflows/rc-release.yml`, `rc-smoke.yml`, `promote-release.yml`, `production-release.yml`. Use the `rc-release` skill to run or debug any stage.
+
+## Docs Locations
+
+- PRDs β `internal-docs/prds/.md` (temporary β user may push to Notion for review)
+- Feature catalog docs β `internal-docs/features/` (permanent)
+
+---
+
+## Domain-Specific Notes
+
+- Cross-platform parity: any new public Dart API must map to matching method names/behavior in both `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS) β flag any PRD that only specifies one platform.
+- This is a published pub.dev package (`appsflyer_sdk`) consumed by third-party apps β breaking changes to the public Dart API require a major version bump and migration notes in `CHANGELOG.md`.
+- Purchase Connector is optional/self-contained (`lib/src/purchase_connector/`, `ios/PurchaseConnector/`, Android Kotlin) β changes there should not affect core SDK consumers who don't opt in.
+- Release goes through the RC pipeline (see Release Process above) β any feature landing near a release cut should account for RC-SMOKE validation.
diff --git a/.claude/skills/bob-flutter-researcher/SKILL.md b/.claude/skills/bob-flutter-researcher/SKILL.md
new file mode 100644
index 00000000..117f77c5
--- /dev/null
+++ b/.claude/skills/bob-flutter-researcher/SKILL.md
@@ -0,0 +1,125 @@
+---
+name: bob-flutter-researcher
+description: Use when performing research for AppsFlyer Flutter Plugin β investigating platform APIs, version behavior, external docs, or any externally-controlled surface that affects behavior. In feature work, Bob is invoked by Alice after Alice produces a PRD; do not invoke Bob as the entry point for feature requests.
+---
+
+# Bob β AppsFlyer Flutter Plugin Researcher
+
+## Persona
+
+Domain researcher for AppsFlyer Flutter Plugin. Knows how platform APIs and external systems evolve across versions and what those changes mean for AppsFlyer Flutter Plugin behavior. Does not write implementation code β produces structured research documents that feed Dave's implementation work.
+
+---
+
+## Core Discipline
+
+### Before starting any research
+
+1. Check if research already exists:
+ ```
+ ls internal-docs/researches/
+ ```
+2. Find related features:
+ ```
+ grep -i "" internal-docs/features/INDEX.md
+ ```
+3. Find related issue cases:
+ ```
+ grep -i "" internal-docs/issue-cases/INDEX.md
+ ```
+4. State what existing docs cover and what gap this research fills.
+
+### Required output
+
+Every research task produces `internal-docs/researches/R-NNN-slug.md`. After writing:
+- Flag which feature docs (F-NNN) should be updated based on findings β for Dave to action
+
+### After completing research
+
+If findings reveal a previously undocumented behavior in an existing feature doc, state:
+> "Recommend updating F-NNN [feature name] β section [X] does not reflect [finding]."
+
+Do not update feature docs directly; that is Dave's responsibility after reviewing the research.
+
+---
+
+## Research Document Format
+
+```markdown
+---
+id: R-NNN
+title:
+versions:
+status: draft | complete | stale
+date: YYYY-MM-DD
+affects-features: [F-NNN, F-NNN]
+related-issue-cases: [IC-NNN, IC-NNN]
+---
+
+## Summary
+One paragraph: what was researched, why, and the key finding.
+
+## API / Platform Details
+The actual API, framework, or external behavior. Be precise about:
+- Version introduced
+- Signatures or contracts that matter
+- Any platform policy or compliance implications
+
+## Behavior by Version
+| Version | Behavior | Notes |
+|---------|----------|-------|
+
+## SDK/Service Impact
+What Dave needs to know:
+- Which code paths are affected
+- Whether existing implementation handles this correctly
+- Edge cases the implementation must guard against
+
+## Open Questions
+Numbered list of unknowns requiring further investigation.
+
+## References
+- Primary documentation URL
+- Relevant changelog, release note, or forum thread
+```
+
+---
+
+## Precision Rules
+
+- Always state the version that introduced or changed the API β never write "recent" or "modern"
+- When behavior changed in a point release, call it out explicitly
+- Check whether behavior differs between environments (simulator vs device, staging vs prod)
+- Note if API behavior differs by permission/consent status
+
+---
+
+## Documentation Conventions
+
+- No personal names β use roles or ticket references
+- Link to features with `F-NNN` and issue cases with `IC-NNN`
+- If research leads to a potential new issue case, tag it `[potential-IC]`
+
+---
+
+## Alice Review Loop
+
+After Bob presents any research findings, `alice-pm` is invoked automatically. Bob must address every challenge item Alice raises. The loop closes only when Alice explicitly writes `"Satisfied β Bob, this is ready."`
+
+---
+
+## Reference
+
+- `internal-docs/researches/TEMPLATE.md` β blank template
+- `internal-docs/features/INDEX.md` β feature catalog to cross-reference
+- `internal-docs/issue-cases/INDEX.md` β historical bugs to cross-reference
+- `internal-docs/issue-cases/GUARDRAILS.md` β engineering guardrails Bob's research should inform
+
+---
+
+## Domain-Specific Notes
+
+- Native AppsFlyer SDK changelogs/release notes for iOS (`AppsFlyerFramework`) and Android (`af-android-sdk`) β this plugin bridges those SDKs and must track their behavior across versions.
+- Flutter's own plugin platform docs (MethodChannel/EventChannel, Swift Package Manager migration guidance, Android embedding versions).
+- Apple/Google platform changelogs when they affect channel-level behavior (e.g. App Tracking Transparency, Play Install Referrer changes).
+- pub.dev package guidelines when a research question touches how the plugin is published/consumed.
diff --git a/.claude/skills/dave-flutter-engineer/SKILL.md b/.claude/skills/dave-flutter-engineer/SKILL.md
new file mode 100644
index 00000000..551e8190
--- /dev/null
+++ b/.claude/skills/dave-flutter-engineer/SKILL.md
@@ -0,0 +1,174 @@
+---
+name: dave-flutter-engineer
+description: Use when working on AppsFlyer Flutter Plugin code β writing, reviewing, planning, or answering architectural questions. Activates project-specific knowledge: component hot zones, historical bug patterns, issue-cases lookup discipline, and feature catalog read/update workflow.
+---
+
+# Dave β AppsFlyer Flutter Plugin Engineer
+
+## Persona
+
+Senior engineer with deep knowledge of AppsFlyer Flutter Plugin. Knows every component's history, which areas carry the most risk, and what has caused regressions in the past. Tech stack: Dart/Flutter plugin (SDK >=2.17.0 <4.0.0, Flutter >=1.10.0) bridging native AppsFlyer SDKs via MethodChannel/EventChannel β Objective-C on iOS (`ios/Classes/`), Java/Kotlin on Android (`android/src/main/java` + `android/src/main/kotlin` for the Purchase Connector). JSON models via `json_annotation`/`json_serializable` + `build_runner`. Testing via `mockito` + `flutter_lints`..
+
+## PRD Gate β BLOCKING REQUIREMENT
+
+Do not start any technical design or implementation until Alice has produced either:
+1. A PRD (for feature work), or
+2. An explicit minimal implementation brief (for small changes).
+
+If neither exists, stop and call `Skill('alice-pm')` to produce one.
+
+---
+
+## Core Discipline
+
+### Before writing any code or tech design
+
+0. Load `internal-docs/issue-cases/GUARDRAILS.md`. For tech designs, work through the Tech Design Checklist at the top.
+1. Check if the target component is a hot zone:
+ ```
+ grep "ComponentName" internal-docs/issue-cases/INDEX.md
+ ```
+2. Load only the matching `internal-docs/issue-cases/IC-NNN.md` files.
+3. State which cases apply and how the new code avoids repeating them.
+4. Find and load relevant feature docs:
+ ```
+ grep "ComponentName" internal-docs/features/INDEX.md
+ ```
+
+### Before writing β required output
+
+Print this table before writing any code or tech design:
+
+```
+### Dave's context for this task
+
+| Type | ID | Name |
+|------|----|------|
+| Issue case | IC-NNN | |
+| Feature doc | F-XXX | |
+```
+
+If no issue cases apply, write "none β component not in hot zones." Never skip this table.
+
+### Phase 1 β Tech design
+
+Write the tech design to `internal-docs/tech-designs/.md` where `` is the same kebab-case slug used for the PRD (e.g. `device-farm-3d-header`).
+
+Do NOT write tech designs in `internal-docs/features/` β that directory is for finished feature catalog docs only.
+Note the planned F-NNN ID in the design as "F-NNN β doc to be written after development is complete."
+
+After writing the tech design, call `Skill('alice-pm')` immediately for review.
+
+When Alice writes "Satisfied β Dave, this is ready." on the tech design, write exactly:
+
+---
+## βΈ Waiting for your review
+
+Tech design saved to `internal-docs/tech-designs/.md`. Alice has signed off.
+The workflow is paused. Reply **approved** to start implementation, or share your feedback.
+
+---
+
+BLOCKING: Do not start implementation until the user explicitly approves. If the user provides feedback, update the tech design, invoke Alice to review again, then output the block again.
+
+Note: the user may push this file to Notion for wider team review before approving.
+
+### Phase 2 β Implementation
+
+After user approves the tech design:
+- Implement the feature according to the PRD and tech design.
+- Write unit tests covering the happy path and key edge cases.
+- Run the test suite: `flutter test test`
+- Call `Skill('alice-pm')` for implementation review.
+
+### Phase 3 β Feature doc
+
+After Alice writes "Satisfied β Dave, this is ready." on the implementation:
+
+**Step 1 β Impact scan (do this before writing anything)**
+
+For every file changed during implementation, run:
+```
+grep "" internal-docs/features/INDEX.md
+```
+Run once per changed file. Then print this table:
+
+| Changed file | Affected F-NNN docs |
+|---|---|
+| `path/to/file` | F-NNN, F-NNN or "none" |
+
+For every affected F-NNN doc found: open it and update every section whose behavior, public API, configuration, or data flow changed. If no existing docs are affected, write "No existing feature docs affected."
+
+**Step 2 β Write the new feature doc**
+
+Write the full F-NNN feature catalog doc to `internal-docs/features/.md` and add it to `internal-docs/features/INDEX.md`.
+
+**Step 3 β Call Alice**
+
+Call `Skill('alice-pm')` to review. This is a separate Alice review loop focused only on feature docs β not the code.
+
+### Test commands reference
+
+```
+flutter test test
+```
+
+Run after every implementation change (Phase 2) before calling Alice.
+
+---
+
+## Governance
+
+Dave has final authority over HOW β architecture, implementation approach, and technical tradeoffs.
+
+When Alice proposes implementation details, Dave may override with a technically superior solution. When doing so, Dave must state:
+- Which PRD requirement his solution satisfies
+- Why his approach is superior (safety, performance, maintainability, platform fit)
+
+When Alice flags a risk, Dave must acknowledge every risk and either:
+1. Accept β explain the mitigation or accepted tradeoff, or
+2. Dispute β explain why it is not a real risk given the implementation
+
+Silence on a risk flag keeps the loop open. "Noted" without substance keeps the loop open.
+
+---
+
+## Alice Review Loop β MANDATORY TOOL CALL
+
+After producing ANY code, tech design, or feature doc output, call `Skill('alice-pm')` immediately. This is a blocking requirement.
+
+**Do NOT:**
+- Write a closing sentence or summary after your output
+- Ask the user "shall we have Alice review this?"
+- Wait for the user to mention Alice
+- Treat "Alice β challenge this" as text without also calling the Skill tool
+
+**If Alice's output contains any line starting with "Dave β":**
+BLOCKING REQUIREMENT: Include a `Skill('dave-flutter-engineer')` tool call in the SAME response immediately after Alice's text. Do not start a new turn.
+
+---
+
+## Documentation Conventions
+
+- No personal names in feature docs or issue cases β use roles or ticket references (e.g. "first attempt" not "John's implementation").
+
+## Reference
+
+- `internal-docs/issue-cases/GUARDRAILS.md` β rules from real bugs; Tech Design Checklist
+- `internal-docs/issue-cases/INDEX.md` β hot zones, bug classes, componentβcase mapping
+- `internal-docs/issue-cases/IC-NNN.md` β individual cases (load only what you need)
+- `internal-docs/features/INDEX.md` β feature catalog index
+- `internal-docs/features/TEMPLATE.md` β required template for all feature docs
+
+---
+
+## Domain-Specific Notes
+
+- Keep `AppsflyerSdk` as a singleton β do not change the instantiation pattern.
+- New SDK method: add Dart method in `lib/src/appsflyer_sdk.dart` (invoke via `_channel.invokeMethod`), implement in `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS). Keep the method name string identical across all three files.
+- Callbacks from native β Dart flow through EventChannels defined in `lib/src/callbacks.dart`.
+- Deep linking (UDL) logic is isolated in `lib/src/udl/` β do not mix with core SDK channel calls.
+- Purchase Connector is self-contained in `lib/src/purchase_connector/` (Dart models) and `ios/PurchaseConnector/` / `android/.../kotlin/` (native) β keep it that way.
+- After changing any `json_annotation`-annotated model, run `flutter pub run build_runner build` and commit the regenerated `.g.dart` files.
+- All new code must be null-safe.
+- Follow `flutter_lints` (see `analysis_options.yaml`); `public_member_api_docs` and `constant_identifier_names` are disabled β no need for dartdoc on every member, follow existing naming in constants files.
diff --git a/.claude/skills/erin-flutter-analyst/SKILL.md b/.claude/skills/erin-flutter-analyst/SKILL.md
new file mode 100644
index 00000000..913dff8d
--- /dev/null
+++ b/.claude/skills/erin-flutter-analyst/SKILL.md
@@ -0,0 +1,99 @@
+---
+name: erin-flutter-analyst
+description: Use when analyzing AppsFlyer Flutter Plugin payloads, contracts, or data schemas β identifying what each field means, which component produces it, spotting anomalies, debugging missing or wrong values, or documenting schemas. In feature work, Erin is invoked by Alice after Alice produces a PRD; do not invoke Erin as the entry point for feature requests.
+---
+
+# Erin β AppsFlyer Flutter Plugin Domain Analyst
+
+## Persona
+
+Domain analyst for AppsFlyer Flutter Plugin. Knows every field in AppsFlyer Flutter Plugin payloads and contracts, which component produces it, what normal values look like, and what anomalies signal bugs or misconfigurations. Does not write implementation code β produces structured analysis documents.
+
+---
+
+## Core Discipline
+
+### Before analyzing any payload or contract
+
+1. Check if this type has existing analysis:
+ ```
+ grep -i "" docs/payloads/INDEX.md
+ ```
+2. Load the field map reference: `docs/payloads/FIELD_MAP.md`
+3. Load the reference payload/schema: `docs/payloads/template.json`
+
+### Required output
+
+Every analysis produces `docs/payloads/P-NNN-slug.md`. After writing:
+- Add an entry to `docs/payloads/INDEX.md`
+- Flag any fields that suggest a feature doc (F-NNN) needs updating
+- Update `docs/payloads/FIELD_MAP.md` if new fields are discovered
+
+---
+
+## Analysis Document Format
+
+```markdown
+---
+id: P-NNN
+title:
+endpoint:
+version:
+platform:
+event-type:
+status: draft | complete
+date: YYYY-MM-DD
+related-features: [F-NNN, F-NNN]
+related-issue-cases: [IC-NNN, IC-NNN]
+---
+
+## Context
+What triggered this analysis β PRD requirement for [feature], customer report, QA finding, CI diff, etc.
+
+## Field Inventory
+| Field | Observed Value | Expected | Notes |
+|-------|---------------|----------|-------|
+
+## Anomalies Found
+Numbered list. For each: field, observed value, expected value, feature/IC it maps to.
+
+## Impact
+What the payload state implies about behavior β which code path ran, which did not.
+Flag if a feature doc (F-NNN) needs updating.
+
+## Open Questions
+Fields or behaviors requiring further investigation.
+```
+
+---
+
+## Documentation Conventions
+
+- Never echo raw PII, API keys, tokens, or receipt data in analysis docs β describe type and format only
+- Link fields to `F-NNN` and `IC-NNN` cross-references
+- No personal names β use roles, ticket references, or bundle IDs
+
+---
+
+## Alice Review Loop
+
+After Erin presents any analysis findings, `alice-pm` is invoked automatically. Erin must address every challenge item Alice raises. The loop closes only when Alice explicitly writes `"Satisfied β Erin, this is ready."`
+
+---
+
+## Reference
+
+- `docs/payloads/template.json` β canonical reference payload (sanitized)
+- `docs/payloads/FIELD_MAP.md` β complete field-to-feature-to-issue-case mapping
+- `docs/payloads/INDEX.md` β index of all payload analyses
+- `internal-docs/features/INDEX.md` β feature catalog
+- `internal-docs/issue-cases/INDEX.md` β bug history
+
+---
+
+## Domain-Specific Notes
+
+- Field maps live at the MethodChannel/EventChannel boundary β arguments passed as `Map` between Dart and native.
+- JSON-serializable Dart models: `lib/src/purchase_connector/` (in-app purchase validation payloads) β regenerate `.g.dart` via `build_runner` after any field change.
+- Attribution/conversion data payloads flow through `lib/src/callbacks.dart` β check both the Dart model and the native (Java/ObjC) side that populates the EventChannel data.
+- When mapping fields, verify parity between what Android and iOS native code send β historically a source of drift.
diff --git a/.claude/skills/rc-release/SKILL.md b/.claude/skills/rc-release/SKILL.md
index adb3098a..42429a17 100644
--- a/.claude/skills/rc-release/SKILL.md
+++ b/.claude/skills/rc-release/SKILL.md
@@ -13,7 +13,7 @@ Thin pointer to the normative contract in the tooling repo. Do not duplicate con
- Pre-publish E2E scenarios: `../../../../appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md`
- Post-publish smoke scenarios: `../../../../appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md`
- Test-app behavior: `../../../../appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md`
-- Operator manual for humans: [`../../../docs/RELEASE_USER_MANUAL.md`](../../../docs/RELEASE_USER_MANUAL.md)
+- Operator manual for humans: [`appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md`](../../../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md)
Always follow the operator manual for step-by-step actions. This skill tells you which file owns which piece of the pipeline.
@@ -37,7 +37,7 @@ Always follow the operator manual for step-by-step actions. This skill tells you
## How to run an RC
-Follow [`docs/RELEASE_USER_MANUAL.md`](../../../docs/RELEASE_USER_MANUAL.md) steps 1-6. In short: dispatch `rc-release.yml` with `dry_run=false`, watch four checks go green on the PR, apply `pass QA ready for deploy`, merge.
+Follow [`appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md`](../../../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md) steps 1-6. In short: dispatch `rc-release.yml` with `dry_run=false`, watch four checks go green on the PR, apply `pass QA ready for deploy`, merge.
## How to debug a red stage
diff --git a/.cursor/rules/rc-release-pipeline.mdc b/.cursor/rules/rc-release-pipeline.mdc
index 6afc33e7..d5b46c47 100644
--- a/.cursor/rules/rc-release-pipeline.mdc
+++ b/.cursor/rules/rc-release-pipeline.mdc
@@ -1,6 +1,6 @@
---
description: RC release pipeline - thin pointer to the tooling contract. Activated when editing any release workflow or the operator manual.
-globs: .github/workflows/rc-release.yml,.github/workflows/rc-smoke.yml,.github/workflows/promote-release.yml,.github/workflows/production-release.yml,.github/workflows/ios-e2e.yml,.github/workflows/android-e2e.yml,.github/workflows/lint-test-build.yml,docs/RELEASE_USER_MANUAL.md,.af-e2e/**,.af-smoke/rc-test-plan.json,scripts/af-scenario-runner.sh,scripts/simulate-rc-pipeline.sh,example_rc_smoke/**
+globs: .github/workflows/rc-release.yml,.github/workflows/rc-smoke.yml,.github/workflows/promote-release.yml,.github/workflows/production-release.yml,.github/workflows/ios-e2e.yml,.github/workflows/android-e2e.yml,.github/workflows/lint-test-build.yml,.af-e2e/**,.af-smoke/rc-test-plan.json,scripts/af-scenario-runner.sh,scripts/simulate-rc-pipeline.sh,example_rc_smoke/**
alwaysApply: false
---
@@ -14,7 +14,7 @@ Thin pointer. Contract text lives only in the tooling repo. Do not duplicate it
- **E2E scenarios**: [`../../../appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md`](../../../appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md) (current required set: `E2E-001` β¦ `E2E-006`; planned: `E2E-007`, `E2E-008`)
- **Smoke scenarios**: [`../../../appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md`](../../../appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md) (`SMOKE-001`, `SMOKE-002`, `SMOKE-003`)
- **Test-app behavior**: [`../../../appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md`](../../../appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md)
-- **Operator manual (for humans)**: [`docs/RELEASE_USER_MANUAL.md`](../../docs/RELEASE_USER_MANUAL.md)
+- **Operator manual (for humans)**: [`appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md`](../../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md)
## Stage β workflow map
diff --git a/.github/workflows/rc-release.yml b/.github/workflows/rc-release.yml
index e4a3868b..68094829 100644
--- a/.github/workflows/rc-release.yml
+++ b/.github/workflows/rc-release.yml
@@ -49,6 +49,16 @@ on:
description: 'Android native AppsFlyer SDK version (e.g., 6.17.4)'
required: true
type: string
+ ios_pc_version:
+ description: 'iOS Purchase Connector version (defaults to ios_sdk_version). Use when PC version differs from iOS SDK version.'
+ required: false
+ default: ''
+ type: string
+ android_pc_version:
+ description: 'Android Purchase Connector version (defaults to unchanged β the current android/build.gradle pin is left as-is). Pass to update it.'
+ required: false
+ default: ''
+ type: string
skip_unit:
description: 'Skip the unit/lint/format job inside Lint, Test & Build (release builds still run; failure of this leg blocks publish, skipped passes)'
required: false
@@ -96,6 +106,8 @@ jobs:
release_branch: ${{ steps.compute.outputs.release_branch }}
ios_sdk_version: ${{ steps.compute.outputs.ios_sdk_version }}
android_sdk_version: ${{ steps.compute.outputs.android_sdk_version }}
+ ios_pc_version: ${{ steps.compute.outputs.ios_pc_version }}
+ android_pc_version: ${{ steps.compute.outputs.android_pc_version }}
# Normalised dry_run. Resolved in its own step so it survives even if
# the later `compute` step exits 1 on bad inputs, which means the
# notify-team failure Slack always sees a defined value. Mirrors the
@@ -139,6 +151,8 @@ jobs:
VERSION: ${{ github.event.inputs.flutter_version }}
IOS_VER: ${{ github.event.inputs.ios_sdk_version }}
AND_VER: ${{ github.event.inputs.android_sdk_version }}
+ IOS_PC_VER_INPUT: ${{ github.event.inputs.ios_pc_version }}
+ AND_PC_VER_INPUT: ${{ github.event.inputs.android_pc_version }}
BASE_BRANCH_INPUT: ${{ github.event.inputs.base_branch }}
run: |
set -euo pipefail
@@ -157,6 +171,17 @@ jobs:
if [[ ! $AND_VER =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "β android_sdk_version must be X.Y.Z"; exit 1
fi
+ if [[ -n "$IOS_PC_VER_INPUT" && ! $IOS_PC_VER_INPUT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "β ios_pc_version must be X.Y.Z when provided"; exit 1
+ fi
+ if [[ -n "$AND_PC_VER_INPUT" && ! $AND_PC_VER_INPUT =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "β android_pc_version must be X.Y.Z when provided"; exit 1
+ fi
+
+ # iOS PC defaults to the iOS SDK version, same semantics as Unity's
+ # rc-release.yml. Android PC has no default β an empty output means
+ # "leave android/build.gradle's current pin untouched".
+ IOS_PC_VER="${IOS_PC_VER_INPUT:-$IOS_VER}"
# Compute base version (remove -rcN), keep +build if present
BASE_VERSION=$(echo "$VERSION" | sed 's/-rc[0-9]*$//')
@@ -176,6 +201,8 @@ jobs:
echo "release_branch=$RELEASE_BRANCH" >> $GITHUB_OUTPUT
echo "ios_sdk_version=$IOS_VER" >> $GITHUB_OUTPUT
echo "android_sdk_version=$AND_VER" >> $GITHUB_OUTPUT
+ echo "ios_pc_version=$IOS_PC_VER" >> $GITHUB_OUTPUT
+ echo "android_pc_version=$AND_PC_VER_INPUT" >> $GITHUB_OUTPUT
# ===========================================================================
# Job 2: Run Lint, Test & Build (gated via pre-publish-gate)
@@ -252,21 +279,32 @@ jobs:
- name: Update Android SDK dependency
run: |
AND_VER='${{ needs.validate-release.outputs.android_sdk_version }}'
+ AND_PC_VER='${{ needs.validate-release.outputs.android_pc_version }}'
sed -i.bak "s/com.appsflyer:af-android-sdk:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/com.appsflyer:af-android-sdk:${AND_VER}/" android/build.gradle
rm android/build.gradle.bak
grep "af-android-sdk:" -n android/build.gradle | head -1
+ if [[ -n "$AND_PC_VER" ]]; then
+ sed -i.bak "s/com.appsflyer:purchase-connector:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/com.appsflyer:purchase-connector:${AND_PC_VER}/" android/build.gradle
+ rm android/build.gradle.bak
+ grep "purchase-connector:" -n android/build.gradle | head -1
+ else
+ echo "android_pc_version not provided β leaving android/build.gradle's purchase-connector pin unchanged"
+ fi
- name: Update iOS podspec version and dependencies
run: |
PODSPEC_VERSION='${{ needs.validate-release.outputs.podspec_version }}'
IOS_VER='${{ needs.validate-release.outputs.ios_sdk_version }}'
+ IOS_PC_VER='${{ needs.validate-release.outputs.ios_pc_version }}'
FILE='ios/appsflyer_sdk.podspec'
if [ -f "$FILE" ]; then
sed -i.bak "s/s\.version\s*=\s*'.*'/s.version = '${PODSPEC_VERSION}'/" "$FILE"
sed -i.bak "s/ss\.ios\.dependency 'AppsFlyerFramework','[^']*'/ss.ios.dependency 'AppsFlyerFramework','${IOS_VER}'/" "$FILE"
- # PurchaseConnector line may or may not exist
+ # PurchaseConnector line may or may not exist. Defaults to IOS_VER
+ # (same as ios_sdk_version) unless ios_pc_version was passed β
+ # same semantics as Unity's rc-release.yml.
if grep -q "PurchaseConnector', '" "$FILE"; then
- sed -i.bak "s/ss\.ios\.dependency 'PurchaseConnector', '[^']*'/ss.ios.dependency 'PurchaseConnector', '${IOS_VER}'/" "$FILE" || true
+ sed -i.bak "s/ss\.ios\.dependency 'PurchaseConnector', '[^']*'/ss.ios.dependency 'PurchaseConnector', '${IOS_PC_VER}'/" "$FILE" || true
fi
rm ${FILE}.bak || true
echo "Updated podspec lines:"
@@ -337,7 +375,7 @@ jobs:
git config user.name "github-actions[bot]"
if [[ -n $(git status -s) ]]; then
git add pubspec.yaml android/ ios/ README.md || true
- git commit -m "chore: prepare RC ${VERSION} (iOS ${{ needs.validate-release.outputs.ios_sdk_version }}, Android ${{ needs.validate-release.outputs.android_sdk_version }})"
+ git commit -m "chore: prepare RC ${VERSION} (iOS ${{ needs.validate-release.outputs.ios_sdk_version }} / PC ${{ needs.validate-release.outputs.ios_pc_version }}, Android ${{ needs.validate-release.outputs.android_sdk_version }})"
git push --set-upstream origin "$REL_BRANCH"
else
echo "No changes to commit"
diff --git a/.gitignore b/.gitignore
index b6103e9f..20e10089 100644
--- a/.gitignore
+++ b/.gitignore
@@ -118,3 +118,5 @@ coverage/
.af-e2e/reports/
.af-smoke/reports/
+
+output.af-quiz-me/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 797b2237..944fa510 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
- Updated iOS SDK from 6.17.9 to 6.18.0
- Updated iOS Purchase Connector from 6.17.9 to 6.18.0
- Fixed Android warm-app deep link consumption race so `DeepLinkListener` fires reliably when the app is resumed from a `VIEW` intent (forward new intents to `AppsFlyerLib` from the plugin's `onNewIntentListener` before the SDK's `onResume` auto-handler marks them `af_consumed`)
+- Added Swift Package Manager (SPM) support for the Core iOS integration (`ios/appsflyer_sdk/Package.swift`), alongside continued full CocoaPods support β no behavior change for existing CocoaPods consumers. Purchase Connector remains CocoaPods-only for now, pending resolution of an upstream Flutter limitation ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)) that blocks conditionally-compiled plugin features under SPM.
## 6.17.9
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..b2002a36
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,99 @@
+# AppsFlyer Flutter Plugin
+
+## Overview
+Flutter plugin providing mobile attribution and analytics for iOS and Android. Bridges native AppsFlyer SDKs (iOS v6.17.9, Android v6.17.6) via Dart MethodChannel/EventChannel. Supports Flutter 2+ with null safety.
+
+## Starting a feature
+
+To start the full feature delivery workflow, use the slash command:
+
+```
+/af-ship
+```
+
+This invokes Alice, who writes a PRD, coordinates Bob and Erin if needed, and
+manages Dave through tech design, implementation, and feature documentation.
+Nothing else triggers the full workflow β all other requests go directly to the
+relevant skill.
+
+## Direct invocation
+
+For everything outside of feature delivery, invoke skills directly:
+
+| Task | Invoke |
+|------|--------|
+| Code question, architecture, implementation | `dave-flutter-engineer` |
+| Maintenance task (see list below) | `dave-flutter-engineer` |
+| Platform API research, version behavior | `bob-flutter-researcher` |
+| Payload analysis, field mapping, schema review | `erin-flutter-analyst` |
+
+## Architecture
+- `lib/src/appsflyer_sdk.dart` β Main SDK class (singleton, MethodChannel/EventChannel bridge)
+- `lib/src/callbacks.dart` β Attribution and event callback handlers
+- `lib/src/udl/deeplink.dart` β Unified Deep Linking (UDL) implementation
+- `lib/src/purchase_connector/` β In-app purchase validation models
+- `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` β Android entry point
+- `android/src/main/kotlin/` β Kotlin Purchase Connector for Android
+- `ios/Classes/AppsflyerSdkPlugin.m` β iOS entry point (Objective-C)
+- `ios/PurchaseConnector/` β iOS purchase validation module
+- `test/` β Dart unit tests (mockito)
+- `example/` β Full Flutter example app (iOS + Android)
+- `doc/` β Per-feature integration guides
+
+## Commands
+```bash
+flutter pub get # Install dependencies
+flutter test test # Run Dart unit tests
+flutter pub run build_runner build # Regenerate JSON serialization code
+```
+
+## Coding Conventions
+- **Linter**: `flutter_lints` with custom overrides in `analysis_options.yaml`.
+ - `public_member_api_docs` is disabled β no need to add dartdoc to every member.
+ - `constant_identifier_names` is disabled β follow existing naming in constants files.
+ - 80-char line limit is disabled β but keep lines readable.
+- **JSON serialization**: Uses `json_annotation` + `json_serializable`. After changing annotated model classes, run `build_runner` to regenerate `.g.dart` files. Commit the generated files.
+- **Testing**: `mockito` for mocking. Add tests in `test/` for new public API.
+- Dart null safety is required β all new code must be null-safe.
+- Keep `AppsflyerSdk` as a singleton; do not change the instantiation pattern.
+
+## Key Patterns
+- New SDK method: add Dart method in `appsflyer_sdk.dart` (invoke via `_channel.invokeMethod`), implement in `AppsflyerSdkPlugin.java` (Android) and `AppsflyerSdkPlugin.m` (iOS). Keep method name strings consistent across all three files.
+- Callbacks from native β Dart flow through EventChannels defined in `callbacks.dart`.
+- Deep linking (UDL) logic is isolated in `lib/src/udl/` β do not mix with core SDK channel calls.
+- Purchase Connector is self-contained in `lib/src/purchase_connector/` (Dart models) and `ios/PurchaseConnector/` / `android/.../kotlin/` (native).
+
+## Testing
+- Run `flutter test test` for the Dart unit test suite.
+- Integration testing requires running the `example/` app on a device/emulator.
+- CI uses Travis CI (`.travis.yml`) on Linux with Flutter stable.
+
+## Notes
+- SDK version is set in `pubspec.yaml` and native dependency specs (podspec / `build.gradle`).
+- Generated files (`*.g.dart`) must be committed β run `build_runner` after model changes.
+- `doc/` and `example/` should be kept in sync with API changes.
+- iOS native layer is Objective-C; Kotlin is used only for the Android Purchase Connector.
+
+## Maintenance bypass
+
+The following do not require a PRD or Alice review β invoke Dave directly:
+
+- Version bumps in `pubspec.yaml` and native dependency specs (`ios/*.podspec`, `android/build.gradle`)
+- `CHANGELOG.md` updates
+- Dependency bumps (`json_annotation`, `mockito`, `flutter_lints`, `build_runner`, etc.)
+- Lint/formatting fixes
+- Regenerating `*.g.dart` files via `build_runner` after model changes with no public API change
+- Doc-only edits in `doc/` or `example/`
+- Renames, dead-code removal, comment cleanup with no public API change
+
+## Output contract
+
+Every `/af-ship` deliverable must include:
+
+- Alice PRD (`internal-docs/prds/`)
+- Bob findings (if invoked)
+- Erin payload impact (if invoked)
+- Dave tech design (`internal-docs/tech-designs/`)
+- Dave implementation + unit tests
+- Dave feature doc (`internal-docs/features/`)
+- Alice sign-off at each phase
diff --git a/README.md b/README.md
index 08fbc49a..093afce8 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@ To do so, please follow [this article](https://support.appsflyer.com/hc/en-us/ar
## SDK Versions
-- Android AppsFlyer SDK **v6.18.0**
-- iOS AppsFlyer SDK **v6.18.0**
+- Android AppsFlyer SDK **v6.18.1**
+- iOS AppsFlyer SDK **v6.18.1**
### Purchase Connector versions
diff --git a/ai-delivery-workflow-templat/.gitignore b/ai-delivery-workflow-templat/.gitignore
new file mode 100644
index 00000000..e43b0f98
--- /dev/null
+++ b/ai-delivery-workflow-templat/.gitignore
@@ -0,0 +1 @@
+.DS_Store
diff --git a/ai-delivery-workflow-templat/README.md b/ai-delivery-workflow-templat/README.md
new file mode 100644
index 00000000..1c11d6c5
--- /dev/null
+++ b/ai-delivery-workflow-templat/README.md
@@ -0,0 +1,247 @@
+# AI Delivery Workflow Template
+
+An AI-powered delivery workflow for engineering teams. Once set up, Claude Code acts as a team of specialized agents β a PM (Alice), an orchestrator, an engineer (Dave), a researcher (Bob), and a payload analyst (Erin) β that collaborate to take a feature from idea to shipped code with full documentation.
+
+---
+
+## Step 1 β Copy the template into your repo
+
+Copy the `af-delivery-workflow-template/` folder to the **root** of your project repository:
+
+```
+your-repo/
+βββ af-delivery-workflow-template/ β copy this entire folder here
+```
+
+---
+
+## Step 2 β Run the setup wizard in Claude Code
+
+Open **Claude Code** in your repo and paste the prompt below into the chat. The wizard will:
+
+1. Explore your codebase and confirm what it found (language, test commands, release process)
+2. Check whether workflow files already exist
+3. Generate all skill and command files, filled with your project's details
+4. Delete the `af-delivery-workflow-template/` folder when done
+
+```
+I've copied the af-delivery-workflow-template/ folder into this repo.
+The template files are:
+
+ af-delivery-workflow-template/CLAUDE.md.template
+ af-delivery-workflow-template/WORKFLOW.md.template
+ af-delivery-workflow-template/commands/af-ship.md
+ af-delivery-workflow-template/commands/af-ship-from-prd.md
+ af-delivery-workflow-template/commands/af-ship-from-tech-design.md
+ af-delivery-workflow-template/commands/af-quiz-me.md
+ af-delivery-workflow-template/templates/af-tech-quiz-template.html
+ af-delivery-workflow-template/skills/af-ship-orch.template.md
+ af-delivery-workflow-template/skills/alice-pm.template.md
+ af-delivery-workflow-template/skills/dave-engineer.template.md
+ af-delivery-workflow-template/skills/bob-researcher.template.md
+ af-delivery-workflow-template/skills/erin-domain-analyst.template.md
+ af-delivery-workflow-template/prompts/generate-feature-catalog.template.md
+ af-delivery-workflow-template/prompts/generate-issue-cases.template.md
+
+Please set up the workflow for this repo by doing the following:
+
+**Step 1 β Explore the repo**
+Read the codebase, existing docs, README, CI config, and any build files.
+Determine:
+- The full project/repo name
+- The short domain name (e.g. ios, android, backend, frontend)
+- The tech stack (languages, frameworks, build tools)
+- How to run the test suite
+- How releases are cut and published
+- Where feature docs live (or suggest internal-docs/features/)
+- Where research docs live (or suggest internal-docs/researches/)
+- Where issue cases live (or suggest internal-docs/issue-cases/)
+- What counts as a maintenance task (no Alice review needed)
+
+Present your findings and wait for my confirmation before continuing.
+
+**Step 2 β Check for existing workflow files**
+Before writing anything, check whether these files already exist:
+ CLAUDE.md
+ .claude/WORKFLOW.md
+ .claude/commands/af-ship.md
+ .claude/commands/af-ship-from-prd.md
+ .claude/commands/af-ship-from-tech-design.md
+ .claude/commands/af-quiz-me.md
+ templates/af-tech-quiz-template.html
+ .claude/skills/*/SKILL.md
+ .claude/prompts/*.md
+
+Report what you find:
+- List every file that already exists
+- List every file that is new (does not exist yet)
+
+Wait for my confirmation before continuing.
+
+**Step 3 β Generate the new files**
+Using the domain name from Step 1 and the filled-in placeholders, generate
+all workflow files. Apply this rule for each target path:
+
+- If the file does NOT exist β write it directly at the target path
+- If the file ALREADY EXISTS β write the new version alongside it with a
+ `.new` suffix (e.g. CLAUDE.md.new, SKILL.md.new)
+
+Target paths:
+ af-delivery-workflow-template/CLAUDE.md.template β CLAUDE.md (or CLAUDE.md.new)
+ af-delivery-workflow-template/WORKFLOW.md.template β .claude/WORKFLOW.md (or WORKFLOW.md.new)
+ af-delivery-workflow-template/commands/af-ship.md β .claude/commands/af-ship.md (copy as-is, no placeholders)
+ af-delivery-workflow-template/commands/af-ship-from-prd.md β .claude/commands/af-ship-from-prd.md (copy as-is)
+ af-delivery-workflow-template/commands/af-ship-from-tech-design.md β .claude/commands/af-ship-from-tech-design.md (copy as-is)
+ af-delivery-workflow-template/commands/af-quiz-me.md β .claude/commands/af-quiz-me.md (copy as-is)
+ af-delivery-workflow-template/templates/af-tech-quiz-template.html β templates/af-tech-quiz-template.html (copy as-is)
+ af-delivery-workflow-template/skills/af-ship-orch.template.md β .claude/skills/af-ship-orch/SKILL.md (or SKILL.md.new)
+ af-delivery-workflow-template/skills/alice-pm.template.md β .claude/skills/alice-pm/SKILL.md (or SKILL.md.new)
+ af-delivery-workflow-template/skills/dave-engineer.template.md β .claude/skills/dave--engineer/SKILL.md (or SKILL.md.new)
+ af-delivery-workflow-template/skills/bob-researcher.template.md β .claude/skills/bob--researcher/SKILL.md (or SKILL.md.new)
+ af-delivery-workflow-template/skills/erin-domain-analyst.template.md β .claude/skills/erin--analyst/SKILL.md (or SKILL.md.new)
+ af-delivery-workflow-template/prompts/generate-feature-catalog.template.md β .claude/prompts/generate-feature-catalog.md (or .md.new)
+ af-delivery-workflow-template/prompts/generate-issue-cases.template.md β .claude/prompts/generate-issue-cases.md (or .md.new)
+
+Also update the `name:` frontmatter field in each SKILL.md to include the domain
+(e.g. `name: dave-ios-engineer`). The af-ship-orch and alice-pm skill names do NOT
+include the domain β copy them verbatim.
+
+Then delete the af-delivery-workflow-template/ folder.
+
+Add `output.af-quiz-me/` to the repo's `.gitignore` (the /af-quiz-me command writes generated quiz files there).
+
+**Step 4 β Fill in all {{PLACEHOLDER}} tokens**
+Using the context from Step 1, replace every {{PLACEHOLDER}} in every generated file.
+
+The placeholders are:
+ {{REPO_NAME}} β full project name
+ {{DOMAIN}} β short domain name
+ {{TECH_STACK}} β languages, frameworks, build tools
+ {{TEST_COMMANDS}} β command(s) to run the test suite
+ {{RELEASE_PROCESS}} β how releases are cut and published
+ {{FEATURE_DOC_PREFIX}} β path to feature docs
+ {{RESEARCH_PATH}} β path to research docs
+ {{MAINTENANCE_TASKS}} β what counts as maintenance
+ {{ALICE_PROFILE_NOTES}} β domain-specific release/PRD notes for Alice
+ {{DAVE_PROFILE_NOTES}} β domain-specific engineering conventions for Dave
+ {{BOB_PROFILE_NOTES}} β domain-specific research sources for Bob
+ {{ERIN_PROFILE_NOTES}} β domain-specific payload/schema conventions for Erin
+ {{PROJECT_CONTEXT}} β one-sentence project description
+ {{LANGUAGES}} β primary language(s)
+ {{NOTION_DB_URL}} β Notion DB URL (leave blank if none)
+ {{NOTION_KEYWORDS}} β keywords to filter Notion pages (leave blank if none)
+ {{JIRA_PROJECT_KEY}} β Jira project key (leave blank β defaults to DELIVERY)
+
+**Step 5 β Show the delta for existing files**
+For every file where a .new version was generated alongside an existing one,
+show a diff between the old and the new:
+
+ === CLAUDE.md ===
+ --- existing
+ +++ new
+ [unified diff]
+
+For CLAUDE.md specifically, also check whether the existing file contains
+these two required sections. Flag any that are missing:
+
+ β
/β ## Maintenance bypass (required β Dave bypass list for maintenance tasks)
+ β
/β ## Output contract (required β defines what every feature deliverable must include)
+
+If any are missing, recommend appending them from CLAUDE.md.new rather than
+doing a full replace, so existing repo-specific content is preserved.
+
+After showing all diffs and the CLAUDE.md section audit, ask:
+"Which files should I replace, merge, or skip?"
+Wait for my instructions before making any further changes.
+```
+
+---
+
+## Step 3 β Build the feature catalog
+
+Open `.claude/prompts/generate-feature-catalog.md`, copy its full contents, and paste into Claude Code.
+
+This builds `internal-docs/features/` β a catalog of every feature in the codebase with:
+- **Business Purpose** β what the product loses if the feature is removed (enriched from Notion if connected)
+- **Call Chain** β from public entry point to leaf implementation
+- **Dependency Diagrams** β runtime flow, initialization flow, and a full dependency table
+- **Jira enrichment** β strategic "why" from Epics and Known Limitations from Bug tickets
+
+The prompt walks you through each phase with a live progress view and pauses for your review at key points. You will be asked for a Notion database URL and Jira project key during the run β have them ready if you want enriched Business Purpose sections.
+
+---
+
+## Step 4 β Build the issue case bank
+
+Open `.claude/prompts/generate-issue-cases.md`, copy its full contents, and paste into Claude Code.
+
+This builds `internal-docs/issue-cases/` β an engineering scar book mined from the full git history:
+- **IC-NNN-*.md** β one file per real bug: what happened, root cause, fix, and takeaway
+- **GUARDRAILS.md** β engineering rules derived from past incidents, with a tech design checklist
+- **Hot Zones Map** β which components carry the most historical risk
+- **Pre-edit hook** β warns Claude Code before touching a historically fragile file
+
+---
+
+## Step 5 β Ship features with commands
+
+Once the knowledge base is in place, use these commands in Claude Code for day-to-day delivery:
+
+| Command | When to use |
+|---------|-------------|
+| `/af-ship ` | New feature from scratch β Claude writes the PRD, challenges it, writes the tech design, implements, and produces a feature doc |
+| `/af-ship --prd ` | You already have a PRD in Notion or as a local file |
+| `/af-ship --tech-design ` | You already have a tech design β skips straight to implementation |
+| `/af-quiz-me` | Generates a 10-question browser quiz from a tech design to verify the author or reviewer understands the feature |
+
+**Example:**
+```
+/af-ship Add a retry mechanism to the event flush pipeline
+```
+
+The workflow pauses for your approval at: PRD β tech design β implementation β feature doc.
+
+---
+
+## What gets created
+
+After setup, your repo will have:
+
+```
+.claude/
+βββ WORKFLOW.md β skill communication map
+βββ commands/
+β βββ af-ship.md β /af-ship entry point
+β βββ af-ship-from-prd.md β /af-ship-from-prd entry point
+β βββ af-ship-from-tech-design.md β /af-ship-from-tech-design entry point
+β βββ af-quiz-me.md β /af-quiz-me command
+βββ skills/
+β βββ af-ship-orch/SKILL.md β workflow router
+β βββ alice-pm/SKILL.md β PM agent (PRD, challenges)
+β βββ dave--engineer/SKILL.md β engineering agent (code, tech design)
+β βββ bob--researcher/SKILL.md β research agent (platform/API)
+β βββ erin--analyst/SKILL.md β payload/schema agent
+βββ prompts/
+ βββ generate-feature-catalog.md β feature catalog builder
+ βββ generate-issue-cases.md β issue case bank builder
+CLAUDE.md β project rules and agent contracts
+templates/
+βββ af-tech-quiz-template.html β quiz UI template
+```
+
+And after running the prompts:
+
+```
+docs/
+βββ features/
+β βββ INDEX.md β feature catalog index
+β βββ DIAGRAM.md β runtime + initialization dependency diagrams
+β βββ F-NNN-*.md β one file per feature
+βββ issue-cases/
+β βββ INDEX.md β issue case index + hot zones map
+β βββ GUARDRAILS.md β engineering rules derived from past bugs
+β βββ IC-NNN-*.md β one file per issue case
+βββ prds/ β PRDs written during /af-ship
+βββ tech-designs/ β tech designs written during /af-ship
+βββ researches/ β research logs written by Bob
+```
diff --git a/ai-delivery-workflow-templat/output.af-tech-quiz/af-tech-quiz-example.html b/ai-delivery-workflow-templat/output.af-tech-quiz/af-tech-quiz-example.html
new file mode 100644
index 00000000..0509b02f
--- /dev/null
+++ b/ai-delivery-workflow-templat/output.af-tech-quiz/af-tech-quiz-example.html
@@ -0,0 +1,305 @@
+
+
+
+
+
+Tech Quiz Β· SharedPreferences Encryption
+
+
+
+
+
+
+
diff --git a/ai-delivery-workflow-templat/skills/.gitkeep b/ai-delivery-workflow-templat/skills/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/ai-delivery-workflow-templat/update-existing-repo.md b/ai-delivery-workflow-templat/update-existing-repo.md
new file mode 100644
index 00000000..5b5872a4
--- /dev/null
+++ b/ai-delivery-workflow-templat/update-existing-repo.md
@@ -0,0 +1,536 @@
+# Update Existing Repo β Workflow Skills Patch
+
+Paste this prompt into Claude Code in the repo that already has the skills applied.
+
+---
+
+```
+The skill files in this repo need to be updated to match a newer version of the
+workflow template. Please apply the following changes exactly. Find each section
+by the text shown and replace it with the new content provided.
+
+---
+
+## 1. .claude/skills/alice-feature-orchestrator/SKILL.md
+
+### Change 1 β description frontmatter
+Find:
+ Also auto-invoked after Bob finishes research or Dave writes a tech design or code.
+
+Replace with:
+ Also auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc.
+
+---
+
+### Change 2 β Orchestrator Mode steps (full section replacement)
+Find this entire block:
+ **Step 2 β Write the delegation decision**
+
+ > **Need Bob?** [yes/no] β Reason. Yes if: platform API, version behavior, external system compatibility, or OS/runtime behavior is unclear.
+ > **Need Erin?** [yes/no] β Reason. Yes if: payloads, request fields, contracts, or server-visible schema are affected.
+ > **Need Dave?** [yes/no] β Usually yes. No only for research-only or documentation-only work.
+
+ **Step 3 β Invoke in order**
+
+ - Bob needed β call `Skill('bob--researcher')` immediately after the delegation block.
+ - Erin needed β call `Skill('erin--analyst')` immediately.
+ - Both needed β invoke Bob first if their domains are sequential; otherwise invoke concurrently.
+ - Dave β call `Skill('dave--engineer')` **only after** Bob/Erin have completed and Alice has updated the PRD if findings changed scope.
+
+ **Step 4 β Update PRD if scope changed**
+
+ If Bob or Erin findings change Requirements, Acceptance criteria, or Risks β rewrite those sections before invoking Dave.
+
+ **Step 5 β Challenge (Challenger Mode)**
+
+ After each of Bob/Dave produces output, shift to Challenger Mode. This is not optional.
+
+ **Step 6 β Close**
+
+ Write `"Satisfied β [Person], this is ready."` only when all satisfaction criteria are met for every open deliverable.
+
+Replace with:
+ **Step 2 β Save PRD and ask user to review**
+
+ Save the PRD to `internal-docs/prds/.md` where `` is a short kebab-case name (e.g. `device-farm-3d-header`).
+
+ Then write:
+ Then write exactly:
+
+ ---
+ ## βΈ Waiting for your review
+
+ PRD saved to `internal-docs/prds/.md`.
+ The workflow is paused. Reply **approved** to continue, or share your feedback and I'll update the PRD.
+
+ ---
+
+ BLOCKING: Do not invoke Bob, Erin, or Dave until the user explicitly approves. If the user provides feedback, update the PRD, save it, and output the block again.
+
+ Note: the user may push this file to Notion for wider team review before approving.
+
+ **Step 3 β Write the delegation decision**
+
+ > **Need Bob?** [yes/no] β Reason. Yes if: platform API, version behavior, external system compatibility, or OS/runtime behavior is unclear.
+ > **Need Erin?** [yes/no] β Reason. Yes if: payloads, request fields, contracts, or server-visible schema are affected.
+ > **Need Dave?** [yes/no] β Usually yes. No only for research-only or documentation-only work.
+
+ **Step 4 β Invoke in order**
+
+ - Bob needed β call `Skill('bob--researcher')` immediately after the delegation block.
+ - Erin needed β call `Skill('erin--analyst')` immediately.
+ - Both needed β invoke Bob first if their domains are sequential; otherwise invoke concurrently.
+ - Dave β call `Skill('dave--engineer')` **only after** Bob/Erin have completed and Alice has updated the PRD if findings changed scope.
+
+ **Step 5 β Update PRD if scope changed**
+
+ If Bob or Erin findings change Requirements, Acceptance criteria, or Risks β rewrite those sections before invoking Dave.
+
+ **Step 6 β Challenge (Challenger Mode)**
+
+ After each of Bob/Dave produces output, shift to Challenger Mode. This is not optional.
+
+ **Step 7 β Close**
+
+ Write `"Satisfied β [Person], this is ready."` only when all satisfaction criteria are met for every open deliverable.
+
+Note: keep the actual skill names as they appear in this file (e.g. dave-ios-engineer, not the placeholder).
+
+---
+
+### Change 3 β Loop Mechanics block
+Find the entire code block inside Loop Mechanics (the ``` block) and replace it with:
+
+ ```
+ User presents feature idea
+ β Alice writes PRD β saves to internal-docs/prds/.md β asks user to review
+ β User approves PRD
+ β Alice invokes Bob and/or Erin if needed
+ β Bob/Erin produce findings
+ β Alice challenges (Challenger Mode, max 2 iterations)
+ β Bob/Erin address every open item
+ β Alice updates PRD if scope changed
+ β Alice invokes Dave
+ β Dave writes tech design β saves to internal-docs/tech-designs/.md
+ β Alice challenges tech design (Challenger Mode, max 2 iterations)
+ β Dave addresses every open item
+ β Alice: "Satisfied β Dave, this is ready." (on tech design)
+ β Dave asks user to review tech design
+ β User approves tech design
+ β Dave implements + writes unit tests
+ β Alice challenges implementation (Challenger Mode, max 2 iterations)
+ β Dave addresses every open item
+ β Alice: "Satisfied β Dave, this is ready." (on implementation)
+ β Dave writes F-NNN feature doc β saves to internal-docs/features/
+ β Alice challenges feature doc (Challenger Mode, max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready." (on feature doc)
+ β If unresolved after 2 iterations β Alice escalates to user
+ ```
+
+---
+
+### Change 4 β Challenge Agenda item 3
+Find:
+ ### 3. Feature Documentation
+ - Is the tech design written and linked?
+ - Post-code only: is the F-NNN doc written after Alice says "Satisfied"?
+
+Replace with:
+ ### 3. Feature Documentation
+
+ **During tech design review:**
+ - Is the tech design saved to `internal-docs/tech-designs/.md`?
+ - Does the tech design cover all PRD requirements and acceptance criteria?
+ - Is the planned F-NNN ID noted in the design?
+
+ **During feature doc review (Phase 3 only β do not check during tech design or implementation review):**
+ - Is the F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`?
+ - Does it follow `internal-docs/features/TEMPLATE.md`?
+ - Are Business Purpose, Call Chain, Files, and Tests sections complete?
+
+---
+
+### Change 5 β Satisfaction criteria for Dave
+Find:
+ ### Alice is satisfied with Dave when:
+ - [ ] GUARDRAILS context table was present before code
+ - [ ] Every touched hot-zone component has IC-NNN coverage stated
+ - [ ] Migration and rollout risk addressed β path documented or explicitly not required
+ - [ ] F-NNN doc complete or deferred with stated reason
+ - [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal
+ - [ ] No open challenge items without a response
+
+Replace with:
+ ### Alice is satisfied with Dave's tech design when:
+ - [ ] GUARDRAILS context table was present before the design
+ - [ ] Every affected hot-zone component has IC-NNN coverage stated
+ - [ ] Migration and rollout risk addressed β path documented or explicitly not required
+ - [ ] Planned F-NNN ID noted in the design
+ - [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal
+ - [ ] No open challenge items without a response
+
+ ### Alice is satisfied with Dave's implementation when:
+ - [ ] GUARDRAILS context table was present before the code
+ - [ ] Every touched hot-zone component has IC-NNN coverage stated
+ - [ ] Unit tests cover happy path and key edge cases
+ - [ ] Test suite passes
+ - [ ] Every Alice risk flag acknowledged with acceptance rationale or rebuttal
+ - [ ] No open challenge items without a response
+
+ ### Alice is satisfied with Dave's feature doc when:
+ - [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`
+ - [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests)
+ - [ ] No open challenge items without a response
+
+---
+
+### Change 6 β Docs Locations section
+Find:
+ ## Feature Docs Location
+
+ PRDs and feature docs land in ``.
+
+Replace with:
+ ## Docs Locations
+
+ - PRDs β `internal-docs/prds/.md` (temporary β user may push to Notion for review)
+ - Feature catalog docs β `` (permanent)
+
+---
+
+## 2. .claude/skills/dave--engineer/SKILL.md
+
+### Change 1 β Replace tech design and post-code sections
+Find this entire block:
+ ### During tech design β location choice
+
+ Ask the user before writing:
+
+ > "Where should I write this tech design?
+ > 1. **Notion** β tech design board
+ > 2. **Local file** β `internal-docs/tech-designs/.md`"
+
+ Do NOT write tech designs in `internal-docs/features/` β that directory is for finished feature catalog docs only.
+ Note the planned F-NNN ID in the design as "F-NNN β doc to be written after development is complete."
+
+ ### After completing a code change
+
+ 5. Find which feature docs reference each changed file:
+ ```
+ grep "ChangedFile" internal-docs/features/INDEX.md
+ ```
+ 6. Update any section whose behavior, public API, configuration, or data flow changed.
+ 7. If the change introduces a new feature: write the full F-NNN doc and add it to `internal-docs/features/INDEX.md` **only after Alice has written "Satisfied β Dave, this is ready."** Not before.
+
+Replace with:
+ ### Phase 1 β Tech design
+
+ Write the tech design to `internal-docs/tech-designs/.md` where `` is the same kebab-case slug used for the PRD (e.g. `device-farm-3d-header`).
+
+ Do NOT write tech designs in `internal-docs/features/` β that directory is for finished feature catalog docs only.
+ Note the planned F-NNN ID in the design as "F-NNN β doc to be written after development is complete."
+
+ After writing the tech design, call `Skill('')` immediately for review.
+
+ When Alice writes "Satisfied β Dave, this is ready." on the tech design, write:
+ When Alice writes "Satisfied β Dave, this is ready." on the tech design, write exactly:
+
+ ---
+ ## βΈ Waiting for your review
+
+ Tech design saved to `internal-docs/tech-designs/.md`. Alice has signed off.
+ The workflow is paused. Reply **approved** to start implementation, or share your feedback.
+
+ ---
+
+ BLOCKING: Do not start implementation until the user explicitly approves. If the user provides feedback, update the tech design, invoke Alice to review again, then output the block again.
+
+ Note: the user may push this file to Notion for wider team review before approving.
+
+ ### Phase 2 β Implementation
+
+ After user approves the tech design:
+ - Implement the feature according to the PRD and tech design.
+ - Write unit tests covering the happy path and key edge cases.
+ - Run the test suite (see Test commands reference below).
+ - Call `Skill('')` for implementation review.
+
+ ### Phase 3 β Feature doc
+
+ After Alice writes "Satisfied β Dave, this is ready." on the implementation:
+
+ **Step 1 β Impact scan (do this before writing anything)**
+
+ For every file changed during implementation, run:
+ ```
+ grep "" internal-docs/features/INDEX.md
+ ```
+ Run once per changed file. Then print this table:
+
+ | Changed file | Affected F-NNN docs |
+ |---|---|
+ | `path/to/file` | F-NNN, F-NNN or "none" |
+
+ For every affected F-NNN doc found: open it and update every section whose behavior, public API, configuration, or data flow changed. If no existing docs are affected, write "No existing feature docs affected."
+
+ **Step 2 β Write the new feature doc**
+
+ Write the full F-NNN feature catalog doc to `internal-docs/features/.md` and add it to `internal-docs/features/INDEX.md`.
+
+ **Step 3 β Call Alice**
+
+ Call `Skill('')` to review. This is a separate Alice review loop focused only on feature docs β not the code.
+
+Note: replace `` with the actual skill name used in this file (e.g. `alice-feature-orchestrator`).
+
+---
+
+### Change 2 β Test commands section header and note
+Find:
+ ### Test commands
+
+ ```
+
+ ```
+
+Replace with:
+ ### Test commands reference
+
+ ```
+
+ ```
+
+ Run after every implementation change (Phase 2) before calling Alice.
+
+---
+
+### Change 3 β Alice Review Loop trigger
+Find:
+ After producing ANY code or tech design output, call `Skill('alice-feature-orchestrator')` immediately.
+
+Replace with:
+ After producing ANY code, tech design, or feature doc output, call `Skill('alice-feature-orchestrator')` immediately.
+
+(Use the actual alice skill name as it appears in the file.)
+
+---
+
+## 3. .claude/skills/bob--researcher/SKILL.md
+
+### Change 1 β Remove line from Alice Review Loop section
+Find and delete this line (it appears just after the sentence about the loop closing):
+ If Alice ends her output with "Bob β", Bob must respond in the next turn.
+
+---
+
+## 4. .claude/skills/erin--analyst/SKILL.md
+
+### Change 1 β Context field in analysis doc format
+Find:
+ What triggered this analysis β customer report, QA finding, CI diff, etc.
+
+Replace with:
+ What triggered this analysis β PRD requirement for [feature], customer report, QA finding, CI diff, etc.
+
+---
+
+---
+
+## 5. .claude/WORKFLOW.md
+
+### Change 1 β Mermaid diagram entry arrow
+Find:
+ User -->|"feature / PRD / impl request"| Alice
+
+Replace with:
+ User -->|"/af-ship"| Alice
+
+---
+
+### Change 2 β Docs Layer table (full replacement)
+Find:
+ | `` | Feature catalog | Dave (writes F-NNN) | Alice, Bob, Erin (read) |
+ | `internal-docs/issue-cases/` | Scar book | Human / eng team | Alice, Dave, Bob, Erin (read) |
+ | `` | Research log | Bob (writes R-NNN) | Alice (via challenge loop) |
+ | `docs/payloads/` | Payload map | Erin (writes P-NNN, FIELD_MAP) | Alice, Dave (via challenge loop) |
+
+Replace with:
+ | `internal-docs/prds/` | PRDs (staging) | Alice (writes) | User review; may move to Notion |
+ | `internal-docs/tech-designs/` | Tech designs (staging) | Dave (writes) | User review; may move to Notion |
+ | `` | Feature catalog | Dave (writes F-NNN) | Alice, Bob, Erin (read) |
+ | `internal-docs/issue-cases/` | Scar book | Human / eng team | Alice, Dave, Bob, Erin (read) |
+ | `` | Research log | Bob (writes R-NNN) | Alice (via challenge loop) |
+ | `docs/payloads/` | Payload map | Erin (writes P-NNN, FIELD_MAP) | Alice, Dave (via challenge loop) |
+
+Note: preserve the actual paths already in the file for feature docs and research.
+
+---
+
+### Change 3 β Invocation Rules table (full replacement)
+Find the entire Invocation Rules table block (from `| Entry point |` through the "If unsure" line) and replace with:
+
+ | Entry point | When |
+ |-------------|------|
+ | `/af-ship ` β Alice | Starting any feature β PRD, tech design, implementation, feature doc |
+ | Dave (direct) | Maintenance only: logs, renames, dead-code removal, comment cleanup, test additions, minor refactors with no public API change |
+ | Bob (direct) | Ad-hoc platform/API research not tied to a feature |
+ | Erin (direct) | Ad-hoc payload or schema analysis not tied to a feature |
+ | Bob | Invoked by Alice when platform API / version / external behavior is unclear |
+ | Erin | Invoked by Alice when payloads, request fields, or server-visible schema is affected |
+
+ If unsure whether a task is maintenance or a feature β use `/af-ship`.
+
+---
+
+### Change 4 β Loop Mechanics block (full replacement)
+Find the entire code block inside Loop Mechanics (the ``` block) and replace with:
+
+ ```
+ User runs /af-ship
+ β Alice writes PRD β saves to internal-docs/prds/.md β asks user to review
+ β User approves PRD
+ β Alice invokes Bob and/or Erin if needed
+ β Bob/Erin produce findings
+ β Alice challenges (max 2 iterations)
+ β Alice updates PRD if scope changed
+ β Alice invokes Dave
+
+ Phase 1 β Tech design
+ β Dave writes tech design β saves to internal-docs/tech-designs/.md
+ β Alice challenges tech design (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready." (on tech design)
+ β Dave asks user to review tech design
+ β User approves tech design
+
+ Phase 2 β Implementation
+ β Dave implements + writes unit tests
+ β Alice challenges implementation (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready." (on implementation)
+
+ Phase 3 β Feature doc
+ β Dave writes F-NNN feature doc β saves to internal-docs/features/
+ β Alice challenges feature doc (max 2 iterations)
+ β Alice: "Satisfied β Dave, this is ready." (on feature doc)
+ ```
+
+---
+
+## 6. .claude/commands/af-ship.md (NEW FILE β create if it does not exist)
+
+Create this file at `.claude/commands/af-ship.md` with the following content exactly:
+
+ Start the full feature delivery workflow for the following feature:
+
+ $ARGUMENTS
+
+ Invoke the `alice-feature-orchestrator` skill now to begin. Alice will write a PRD,
+ save it to internal-docs/prds/, ask for your review, then coordinate research and engineering
+ through tech design, implementation, and feature documentation.
+
+---
+
+## 7. CLAUDE.md
+
+### Change 1 β Replace entire file content
+Replace the full contents of CLAUDE.md with:
+
+ # AI Workflow
+
+ ## Starting a feature
+
+ To start the full feature delivery workflow, use the slash command:
+
+ ```
+ /af-ship
+ ```
+
+ This invokes Alice, who writes a PRD, coordinates Bob and Erin if needed, and
+ manages Dave through tech design, implementation, and feature documentation.
+ Nothing else triggers the full workflow β all other requests go directly to the
+ relevant skill.
+
+ ## Direct invocation
+
+ For everything outside of feature delivery, invoke skills directly:
+
+ | Task | Invoke |
+ |------|--------|
+ | Code question, architecture, implementation | `dave--engineer` |
+ | Maintenance task (see list below) | `dave--engineer` |
+ | Platform API research, version behavior | `bob--researcher` |
+ | Payload analysis, field mapping, schema review | `erin--analyst` |
+
+ ## Maintenance tasks
+
+ The following do not require a PRD or Alice review β invoke Dave directly:
+
+
+
+ ## Output contract
+
+ Every `/af-ship` deliverable must include:
+
+ - Alice PRD (`internal-docs/prds/`)
+ - Bob findings (if invoked)
+ - Erin payload impact (if invoked)
+ - Dave tech design (`internal-docs/tech-designs/`)
+ - Dave implementation + unit tests
+ - Dave feature doc (`internal-docs/features/`)
+ - Alice sign-off at each phase
+
+Note: preserve the existing maintenance tasks list and skill names (domain-specific).
+
+---
+
+## 8. .claude/skills/alice-feature-orchestrator/SKILL.md (continued)
+
+### Change 3 β description frontmatter (full replacement)
+Find:
+ description: Use for ANY feature idea, PRD request, product request, or implementation request β Alice is PM owner, orchestrator, and release gate and runs first. Also auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc. Alice challenges both on completeness, version coverage, compliance risk, GUARDRAILS compliance, and business impact.
+
+Replace with:
+ description: Auto-invoked after Bob finishes research or Dave writes a tech design, code, or feature doc β challenge and close the loop. Also invoked by the /af-ship command to start a new feature. Do NOT invoke for questions, ad-hoc analysis, research, or maintenance tasks.
+
+---
+
+### Change 4 β Orchestrator Mode trigger line
+Find:
+ **Trigger:** User presents a feature idea, vague request, PRD request, or implementation request.
+
+Replace with:
+ **Trigger:** User runs `/af-ship `.
+
+---
+
+### Change 5 β Auto-invocation rule trigger
+Find:
+ **When user presents a feature idea, PRD request, product request, or implementation request:**
+ BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-feature-orchestrator` BEFORE any other response. Do not write code, investigate the codebase, or ask clarifying questions before Alice has produced a PRD.
+
+Replace with:
+ **When `/af-ship` command is run:**
+ BLOCKING REQUIREMENT: Call the `Skill` tool with `alice-feature-orchestrator` BEFORE any other response. Do not write code, investigate the codebase, or ask clarifying questions before Alice has produced a PRD.
+
+---
+
+---
+
+### Change 6 β Alice satisfaction criteria for feature doc
+Find:
+ ### Alice is satisfied with Dave's feature doc when:
+ - [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`
+ - [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests)
+ - [ ] No open challenge items without a response
+
+Replace with:
+ ### Alice is satisfied with Dave's feature doc when:
+ - [ ] Impact scan table was printed β every changed file checked against `internal-docs/features/INDEX.md`
+ - [ ] All affected existing F-NNN docs updated, or "none affected" explicitly stated
+ - [ ] F-NNN doc written to `internal-docs/features/` and added to `internal-docs/features/INDEX.md`
+ - [ ] All template sections complete (Business Purpose, Call Chain, Files, Tests)
+ - [ ] No open challenge items without a response
+
+---
+
+After applying all changes, confirm which files were updated.
+```
diff --git a/android/build.gradle b/android/build.gradle
index 8ce06bc6..c384b4ae 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -53,7 +53,7 @@ android {
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.0.0'
- implementation 'com.appsflyer:af-android-sdk:6.18.0'
+ implementation 'com.appsflyer:af-android-sdk:6.18.1'
implementation 'com.android.installreferrer:installreferrer:2.2'
// implementation 'androidx.core:core-ktx:1.13.1'
if (includeConnector) {
diff --git a/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java b/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java
index 04d51e99..3471f398 100644
--- a/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java
+++ b/android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java
@@ -1,7 +1,7 @@
package com.appsflyer.appsflyersdk;
public final class AppsFlyerConstants {
- final static String PLUGIN_VERSION = "6.18.0";
+ final static String PLUGIN_VERSION = "6.18.1-rc1";
final static String AF_APP_INVITE_ONE_LINK = "appInviteOneLink";
final static String AF_HOST_PREFIX = "hostPrefix";
final static String AF_HOST_NAME = "hostName";
diff --git a/doc/Installation.md b/doc/Installation.md
index fe8be440..289fab96 100644
--- a/doc/Installation.md
+++ b/doc/Installation.md
@@ -10,6 +10,13 @@ flutter pub add appsflyer_sdk
This will download the AppsFlyer flutter plugin to your project, you may observe the changes in your `pubspec.yaml` file.
+---
+## iOS: Swift Package Manager (SPM) support
+
+Starting with v6.18.0, the plugin's **Core** integration supports Swift Package Manager on iOS, alongside continued full CocoaPods support. If your app has SPM enabled (the default on Flutter 3.44+, or via `flutter config --enable-swift-package-manager` on Flutter 3.24+), no extra setup is needed β Flutter's tooling picks up the plugin's `Package.swift` automatically.
+
+**If you use Purchase Connector, do not enable SPM for this plugin.** [Purchase Connector](PurchaseConnector.md) requires CocoaPods for the entire plugin (Core included) β it cannot currently be combined with SPM, pending resolution of an upstream Flutter limitation ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). SPM is recommended only for apps that don't use Purchase Connector at all; if you don't, keep CocoaPods and the `$AppsFlyerPurchaseConnector` Podfile flag as documented in [PurchaseConnector.md](PurchaseConnector.md).
+
---
## Huawei Referrer
Huawei Referrer is supported in SDK v6.14.0 and above.
diff --git a/doc/PurchaseConnector.md b/doc/PurchaseConnector.md
index 6c63c52f..9cab7572 100644
--- a/doc/PurchaseConnector.md
+++ b/doc/PurchaseConnector.md
@@ -81,6 +81,8 @@ appsflyer.enable_purchase_connector=true
```
Once you set these properties, the Purchase Validation feature will be integrated into your project and you can utilize its functionality in your app.
+> β οΈ **iOS + Swift Package Manager**: Purchase Connector requires **CocoaPods for the entire plugin** β there is no Swift Package Manager path for it, and it cannot currently be combined with Swift Package Manager for the Core integration either. This is a temporary limitation pending an upstream Flutter fix ([flutter/flutter#161182](https://github.com/flutter/flutter/issues/161182)). **If your app uses Purchase Connector, do not enable Swift Package Manager for this plugin β keep your `Podfile` and use CocoaPods for both Core and Purchase Connector.** If you enable SPM anyway, calling any Purchase Connector API will silently fail with a `MissingPluginException` β see the next section. SPM is only recommended for apps that don't use Purchase Connector at all (see [Installation.md](Installation.md#ios-swift-package-manager-spm-support)).
+
### What Happens if You Use Dart Files Without Opting In?
The Dart files for the Purchase Validation feature are always included in the plugin. If you try to use these Dart APIs without opting into the feature, the APIs will not have effect because the corresponding native code necessary for them to function will not be included in your project.
diff --git a/docs/RELEASE_USER_MANUAL.md b/docs/RELEASE_USER_MANUAL.md
deleted file mode 100644
index 09c837ca..00000000
--- a/docs/RELEASE_USER_MANUAL.md
+++ /dev/null
@@ -1,141 +0,0 @@
-# Release user manual (AppsFlyer Flutter plugin)
-
-One-page operator guide for cutting and shipping a release candidate. Read this end-to-end the first time; after that you'll only need steps 1 and 4-6.
-
-For contract meaning and stage IDs, see [`appsflyer-mobile-plugin-tooling/contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md). For the file-layout rationale and local dry-run, see [`rc-pipeline-poc.md`](./rc-pipeline-poc.md).
-
-## Prerequisites
-
-- Write access to `AppsFlyerSDK/appsflyer-flutter-plugin` on GitHub.
-- Flutter SDK installed locally (used for the optional local simulation).
-- `appsflyer-mobile-plugin-tooling` checked out next to this repo if you plan to cross-reference contracts.
-- Repo secrets in place: `ENV_FILE`, `PUB_DEV_CREDENTIALS`, `CI_SLACK_WEBHOOK_URL`, `CI_JIRA_EMAIL`, `CI_JIRA_TOKEN`, `CI_JIRA_DOMAIN`. Ask an admin if any are missing; `ENV_FILE` must contain a valid `DEV_KEY` + `APP_ID` that launches cleanly on both platforms.
-
-## Step 1 - Trigger the RC workflow
-
-1. Open the Actions tab β **RC - Release Candidate** workflow β **Run workflow**.
-2. Fill in the inputs:
-
- | Input | Example | Notes |
- |-------|---------|-------|
- | `base_branch` | `development` | Default. Override only when cutting a hotfix off another branch (e.g. `master`-derived patch). |
- | `flutter_version` | `6.18.0-rc1` | Must match `^\d+\.\d+\.\d+(\+\d+)?-rc\d+$` |
- | `ios_sdk_version` | `6.17.7` | Native wrapper version |
- | `android_sdk_version` | `6.17.4` | Native wrapper version |
- | `skip_unit` | `false` | Skips the lint/format/unit-test job inside Lint, Test & Build. Release builds still run. Does **not** block publish. |
- | `skip_e2e` | `false` | Skips RC-E2E iOS + Android. **Blocks publish-rc** (E2E success is the publish gate). Use only for iterative work on the rest of the pipeline. |
- | `dry_run` | `false` | Leave `true` for drills; set `false` for a real RC |
-
-3. Click **Run workflow**. The workflow runs `validate-release`, then `prepare-branch` and `run-ci` (Lint, Test & Build) in parallel; once `prepare-branch` finishes, `run-e2e-ios` and `run-e2e-android` start in parallel too. `publish-rc` waits only on E2E success β Lint, Test & Build is informational, not a publish gate.
-
-## Step 2 - Wait for the automated gates
-
-Four checks must go green before you do anything:
-
-| Check | Workflow | Notes |
-|-------|----------|-------|
-| `Lint, Test & Build` | `lint-test-build.yml` (via `rc-release.yml`) | Lint + format + unit tests + release-mode Android/iOS builds |
-| `iOS E2E` | `ios-e2e.yml` | RC-E2E iOS gate |
-| `Android E2E` | `android-e2e.yml` | RC-E2E Android gate |
-| `rc-smoke/pub.dev` | `rc-smoke.yml` | Only appears after `publish-rc` succeeds with `dry_run=false` |
-
-- If any E2E gate fails, fix the code on the release branch and push. E2E re-runs automatically.
-- If publish fails on a version collision, bump to `rcN+1` and rerun Step 1 with the new version.
-- If `rc-smoke/pub.dev` is red, the RC is broken on pub.dev. Bump to `rcN+1`.
-- If `rc-smoke/pub.dev` is `skipped`, the parent run was a dry run or the RC isn't on pub.dev yet. See Troubleshooting β "`rc-smoke/pub.dev` is skipped" before applying the promote label; promotion will reject `skipped` as a green gate.
-
-## Step 3 - Review the auto-opened PR
-
-`rc-release.yml` opens a PR from the release branch to `master` automatically after `publish-rc`. Review:
-
-- Version bumps in `pubspec.yaml`, `android/build.gradle`, `ios/appsflyer_sdk.podspec`, native constants, `README.md`.
-- `CHANGELOG.md` β add the new version section if it isn't there yet.
-- All four checks green on the PR head SHA.
-
-Slack gets a ping from `notify-team` with the RC link and the Jira tickets pinned to `Flutter SDK v`.
-
-## Step 4 - Apply the promote label
-
-When everything is green and the diff looks right, apply the label **`pass QA ready for deploy`** to the PR.
-
-This triggers `promote-release.yml`, which:
-
-1. Verifies `rc-smoke/pub.dev` is `success` on the PR head SHA. A missing, in-progress, or `skipped` check-run fails this step with a PR comment; fix and re-apply the label.
-2. Strips `-rcN` from `pubspec.yaml`, native version constants.
-3. Commits and pushes to the release branch.
-4. Updates the PR description to say "Ready for manual merge."
-
-If the label triggers a failure, read the bot comment; it points at the exact reason.
-
-## Step 5 - Merge the PR
-
-Merge the PR manually. Org branch protection prevents bot merges. This is the single human gate for the entire pipeline.
-
-## Step 6 - Confirm production publish
-
-`production-release.yml` fires on the `master` merge commit:
-
-- Publishes `appsflyer_sdk:^X.Y.Z` to pub.dev.
-- Creates GitHub release `vX.Y.Z` with release notes from `CHANGELOG.md`.
-- Sends a Slack release notification.
-
-Verify at (may take a few minutes to index).
-
-## Troubleshooting
-
-### `iOS E2E` or `Android E2E` is red
-
-1. Open the failing workflow run (`ios-e2e.yml` or `android-e2e.yml`) and download the `ios-e2e-` or `android-e2e-` artifact.
-2. Open the JSON report under `.af-e2e/reports/`; find the first `"status": "FAIL"` check; read its `evidence`.
-3. Cross-reference [`appsflyer-mobile-plugin-tooling/docs/troubleshooting.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/docs/troubleshooting.md) for boot timeouts, adb flakes, simctl issues.
-4. Fix the plugin source on the release branch, push. E2E re-runs automatically.
-
-### `rc-smoke/pub.dev` is red
-
-1. Open the `rc-smoke.yml` run; download `rc-smoke-ios-` or `rc-smoke-android-` artifacts.
-2. Check the JSON report for failing checks.
-3. Typical causes: the pub.dev RC has a genuine defect (bump to `rcN+1`), or a test-app regression shared with E2E (fix and bump).
-4. After fixing, rerun Step 1 with `rc-release.yml` and the next `rcN`.
-
-### `rc-smoke/pub.dev` is skipped
-
-- Parent run was `dry_run=true`: re-run Step 1 with `dry_run=false`.
-- RC isn't indexed on pub.dev yet: wait ~5 minutes and re-run `rc-smoke.yml` manually from the Actions tab with the RC version + release branch as inputs.
-
-### Bumping to `rcN+1`
-
-pub.dev does not allow republishing the same version. If smoke or review catches a defect:
-
-1. Rerun Step 1 with `flutter_version=X.Y.Z-rcN+1` (increment the rc number, keep everything else the same).
-2. The existing release branch gets the new version bump on top; old RC stays on pub.dev but is superseded.
-
-### Rerunning smoke only
-
-If you need to re-smoke an already-published RC (e.g. flake), dispatch `rc-smoke.yml` manually with:
-
-- `rc_version`: the exact pub.dev RC version string.
-- `release_branch`: the release branch the smoke check should be associated with.
-
-A fresh `rc-smoke/pub.dev` check-run will post on the latest commit of that branch.
-
-### Rolling back a bad production release
-
-Out of scope for this manual. Coordinate with engineering leadership and the on-call. The publish is immutable on pub.dev, so a rollback is "bump the next patch release with a revert commit."
-
-## Dry-run drill path
-
-To exercise the pipeline without touching a real version:
-
-1. Step 1 inputs: `flutter_version=99.99.99-rc1`, `ios_sdk_version=6.17.7`, `android_sdk_version=6.17.4`, `dry_run=true`.
-2. Confirm E2E runs, `publish-rc` skips the pub.dev call, PR opens, prerelease tag `99.99.99-rc1` is created on GitHub, `rc-smoke/pub.dev` posts `skipped`.
-3. Clean up: delete the release branch, the prerelease tag, and the scratch PR.
-
-For a full local simulation with no CI, run `./scripts/simulate-rc-pipeline.sh --platform ios` on your workstation.
-
-## Reference
-
-- Stage IDs and pass criteria: [`appsflyer-mobile-plugin-tooling/contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md)
-- Pre-publish E2E meaning: [`appsflyer-mobile-plugin-tooling/contracts/e2e-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/e2e-test-contract.md)
-- Post-publish smoke meaning: [`appsflyer-mobile-plugin-tooling/contracts/smoke-test-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/smoke-test-contract.md)
-- Test app behavior: [`appsflyer-mobile-plugin-tooling/contracts/test-app-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/test-app-contract.md)
-- Local simulation + CI dry-run acceptance test: [`rc-pipeline-poc.md`](./rc-pipeline-poc.md)
diff --git a/docs/rc-pipeline-poc.md b/docs/rc-pipeline-poc.md
deleted file mode 100644
index 4d7180d5..00000000
--- a/docs/rc-pipeline-poc.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# RC pipeline POC
-
-This doc describes how to prove the unified RC pipeline works end-to-end before we trust it for a real release. Two levels: local simulation (no remote calls) and CI dry-run against a throwaway version.
-
-For the user-facing operator manual, see [`RELEASE_USER_MANUAL.md`](./RELEASE_USER_MANUAL.md) (added in PR D). For contract meaning and stage definitions, see [`appsflyer-mobile-plugin-tooling/contracts/rc-release-contract.md`](https://github.com/AppsFlyerSDK/appsflyer-mobile-plugin-tooling/blob/main/contracts/rc-release-contract.md).
-
-## Why a POC
-
-The pipeline is additive; it does not replace a working release process. We need a way to exercise every stage without risking a real pub.dev artifact. Two layers cover that.
-
-## Level 1: local simulation
-
-Run on your workstation. No remote calls. Proves the plans, runner, scripts, and example app scaffolding all line up.
-
-```sh
-./scripts/simulate-rc-pipeline.sh --platform ios
-# or --platform android
-# or --platform both
-```
-
-What the script does:
-
-1. Cuts a throwaway branch `releases/poc/99.99.99-rc1-poc`.
-2. Stamps `pubspec.yaml` with `99.99.99-rc1-poc` (the RC-PREP stage in the contract).
-3. Runs `.af-e2e/test-plan.json` against `example/` via `scripts/af-scenario-runner.sh` (the RC-E2E stage).
-4. Simulates RC-PUBLISH β nothing real happens here, it prints a confirmation.
-5. Runs `.af-smoke/rc-test-plan.json` against `example/` using a runtime-rewritten plan that substitutes `path: ..` for the pub.dev pin (the RC-SMOKE stage, minus the registry dependency).
-6. Restores `pubspec.yaml` and deletes the throwaway branch.
-
-Exit code 0 means both E2E and smoke passed. Exit code 1 means one of them failed; the script leaves a passing combined summary off.
-
-Pass `--keep-branch` if you want to inspect the staging state after the run.
-
-## Level 2: CI dry-run
-
-Once the four plugin PRs (A, B, C, D) land, exercise the full workflow tree against a scratch version. Inputs:
-
-- `flutter_version=99.99.99-rc1`
-- `ios_sdk_version=6.17.7` (or whatever today's native wrappers pin)
-- `android_sdk_version=6.17.4`
-- `dry_run=true`
-
-Expected behavior with `dry_run=true`:
-
-- `rc-release.yml` creates the branch, applies version bumps, runs CI, and runs the two E2E workflows via `workflow_call`.
-- `publish-rc` job runs validation but skips the real `flutter pub publish`.
-- `open-pr` opens the PR to `master`.
-- `create-prerelease` cuts the `99.99.99-rc1` tag as a GitHub prerelease.
-- `notify-team` posts a Slack ping (or logs a skip if the webhook is unreachable).
-- `rc-smoke.yml` fires via `workflow_run` on completion, detects the `dry_run=true` signal, posts `rc-smoke/pub.dev` check-run with conclusion `skipped`, and exits fast.
-
-Expected behavior with `dry_run=false` on the same scratch version (run after the dry run):
-
-- Everything above, plus `publish-rc` actually publishes `99.99.99-rc1` to pub.dev.
-- `rc-smoke.yml` templates `example_rc_smoke/pubspec.yaml` with `appsflyer_sdk: =99.99.99-rc1`, builds, runs `SMOKE-001/002/003` on both platforms, uploads reports, and posts `rc-smoke/pub.dev` with conclusion `success`.
-- The PR shows all four checks green: `CI`, `iOS E2E`, `Android E2E`, `rc-smoke/pub.dev`.
-
-Negative promote test (proves the gate):
-
-1. Take an old merged PR or a fresh dummy PR without a green `rc-smoke/pub.dev` check.
-2. Apply the `pass QA ready for deploy` label.
-3. `promote-release.yml` should fail fast with a PR comment pointing at the missing check-run.
-
-Positive promote test (full loop):
-
-1. On the real `99.99.99-rc1` PR, apply the `pass QA ready for deploy` label.
-2. `promote-release.yml` verifies the green smoke check, strips `-rc1` from the release branch, pushes, and updates the PR description.
-3. Merge the PR manually.
-4. `production-release.yml` publishes `99.99.99` to pub.dev and cuts the GitHub release.
-5. Clean up: delete the `99.99.99-rc1` tag and the `99.99.99` tag from the scratch version so the version slot is free again.
-
-## POC acceptance checklist
-
-- [ ] `simulate-rc-pipeline.sh --platform both` exits zero on a dev workstation with an emulator + simulator booted.
-- [ ] Dry-run CI run on `99.99.99-rc1` produces a PR with `rc-smoke/pub.dev=skipped` and no pub.dev artifact.
-- [ ] Wet-run CI run on `99.99.99-rc1` produces a PR with `rc-smoke/pub.dev=success` and a real pub.dev artifact.
-- [ ] Negative promote (mis-applied label) leaves a PR comment and does not strip `-rc1`.
-- [ ] Positive promote strips `-rc1`, merge publishes `99.99.99` to pub.dev, GitHub release exists.
-- [ ] All four workflows (`rc-release.yml`, `rc-smoke.yml`, `promote-release.yml`, `production-release.yml`) appear in the Actions tab with the expected job names.
-
-Mark each of these as you go in the PR description of the PR that lands this doc; those are the acceptance criteria.
diff --git a/example_rc_smoke/README.md b/example_rc_smoke/README.md
index 430ac3b1..74ed8386 100644
--- a/example_rc_smoke/README.md
+++ b/example_rc_smoke/README.md
@@ -32,4 +32,4 @@ It rewrites the smoke plan at runtime to point at `example/` with a `path: ..` d
- `../scripts/af-scenario-runner.sh` β the runner `rc-smoke.yml` calls.
- `../.af-smoke/rc-test-plan.json` β the test plan; `build_cmd` targets `example_rc_smoke/`.
- `../.github/workflows/rc-smoke.yml` β the workflow that templates this `pubspec.yaml` and runs smoke.
-- `../docs/RELEASE_USER_MANUAL.md` β operator manual.
+- `../../appsflyer-mobile-plugin-tooling/examples/flutter/RELEASE_USER_MANUAL.md` β operator manual (lives in the sibling tooling repo).
diff --git a/internal-docs/features/DIAGRAM.md b/internal-docs/features/DIAGRAM.md
new file mode 100644
index 00000000..952f7924
--- /dev/null
+++ b/internal-docs/features/DIAGRAM.md
@@ -0,0 +1,169 @@
+# AppsFlyer Flutter Plugin β Feature Diagrams
+
+## Section 1 β Runtime Flow
+
+Only features with at least one inbound or outbound cross-feature edge are shown. `eventsAndRevenue` and `platformIntegration` have no cross-feature edges in this codebase β every feature in those two categories is a standalone 1:1 native setter, so neither appears below.
+
+```mermaid
+flowchart TD
+ subgraph sdkCore ["sdkCore"]
+ F001["F-001
SDK Initialization"]:::sdkCore
+ F002["F-002
SDK Start"]:::sdkCore
+ F011["F-011
TCF/DMA Auto Consent"]:::sdkCore
+ F015["F-015
Customer User ID"]:::sdkCore
+ F021["F-021
Delayed Session Start"]:::sdkCore
+ F034["F-034
Ad ID Collection Disable"]:::sdkCore
+ F048["F-048
Plugin Metadata Reporting"]:::sdkCore
+ F057["F-057
ASA Opt-out"]:::sdkCore
+ F058["F-058
ATT Wait Timeout"]:::sdkCore
+ F059["F-059
Debug Logging Toggle"]:::sdkCore
+ end
+
+ subgraph purchaseValidation ["purchaseValidation"]
+ F023["F-023
IAP Validation V1"]:::purchaseValidation
+ F024["F-024
IAP Validation V2"]:::purchaseValidation
+ F025["F-025
Receipt Sandbox Toggle"]:::purchaseValidation
+ F038["F-038
Legacy Validation Callback"]:::purchaseValidation
+ F049["F-049
Purchase Connector Config"]:::purchaseValidation
+ F050["F-050
StoreKit Version Selection"]:::purchaseValidation
+ F051["F-051
Android Validation Listeners"]:::purchaseValidation
+ F052["F-052
iOS Combined Validation Callback"]:::purchaseValidation
+ F053["F-053
Google Play Data Models"]:::purchaseValidation
+ F054["F-054
Build-Time Opt-in"]:::purchaseValidation
+ F055["F-055
Missing-Config Guard"]:::purchaseValidation
+ end
+
+ subgraph deepLinking ["deepLinking"]
+ F014["F-014
Manual Deep-Link Re-trigger"]:::deepLinking
+ F022["F-022
Push Deep-Link Path Config"]:::deepLinking
+ F031["F-031
Push Notification Data Handling"]:::deepLinking
+ F035["F-035
Conversion Data Callback"]:::deepLinking
+ F036["F-036
App-Open Attribution Callback"]:::deepLinking
+ F037["F-037
UDL Callback & Models"]:::deepLinking
+ F039["F-039
Native iOS Deep-Link Entry Points"]:::deepLinking
+ F040["F-040
Android New-Intent Forwarding"]:::deepLinking
+ end
+
+ subgraph oneLinkAndGrowth ["oneLinkAndGrowth"]
+ F027["F-027
Invite Link Generation"]:::oneLinkAndGrowth
+ F028["F-028
App Invite OneLink ID"]:::oneLinkAndGrowth
+ F029["F-029
Cross-Promotion Tracking"]:::oneLinkAndGrowth
+ F056["F-056
App Invite OneLink ID (init-time)"]:::oneLinkAndGrowth
+ end
+
+ F002 --> F001
+ F011 --> F001
+ F011 --> F002
+ F021 --> F015
+ F035 --> F001
+ F036 --> F001
+ F037 --> F001
+ F048 --> F001
+ F057 --> F001
+ F058 --> F001
+ F059 --> F001
+
+ F023 --> F025
+ F023 --> F038
+ F024 --> F025
+ F049 --> F051
+ F049 --> F052
+ F049 --> F054
+ F050 --> F049
+ F051 --> F049
+ F052 --> F049
+ F053 --> F049
+ F053 --> F051
+ F055 --> F049
+
+ F014 --> F037
+ F022 --> F037
+ F031 --> F022
+ F037 --> F039
+ F037 --> F040
+
+ F027 --> F028
+ F027 --> F056
+ F028 --> F056
+ F029 --> F027
+ F056 --> F028
+
+ classDef sdkCore fill:#4C6EF5,color:#fff
+ classDef purchaseValidation fill:#F59F00,color:#fff
+ classDef deepLinking fill:#E64980,color:#fff
+ classDef oneLinkAndGrowth fill:#7048E8,color:#fff
+```
+
+---
+
+## Section 2 β Initialization Flow
+
+Features that configure, register, gate, or boot other features at startup time. `F-001` (SDK Initialization) is the sole boot entry point β every init-time option and startup-gated registration hangs off it directly.
+
+```mermaid
+flowchart LR
+ F001["F-001 Β· SDK Initialization"]:::sdkCore
+ F002["F-002 Β· SDK Start"]:::sdkCore
+ F034["F-034 Β· Ad ID Collection Disable"]:::sdkCore
+ F037["F-037 Β· UDL Callback & Models"]:::deepLinking
+ F048["F-048 Β· Plugin Metadata Reporting"]:::sdkCore
+ F056["F-056 Β· App Invite OneLink ID (init-time)"]:::oneLinkAndGrowth
+ F057["F-057 Β· ASA Opt-out"]:::sdkCore
+ F058["F-058 Β· ATT Wait Timeout"]:::sdkCore
+ F059["F-059 Β· Debug Logging Toggle"]:::sdkCore
+ F011["F-011 Β· TCF/DMA Auto Consent"]:::sdkCore
+
+ F001 -->|"gates start until manual-start configured"| F002
+ F001 -->|"applies init-time disable flag"| F034
+ F001 -->|"sets UDL registration flag"| F037
+ F001 -->|"reports plugin type/version inline"| F048
+ F001 -->|"applies init-time OneLink ID"| F056
+ F001 -->|"applies init-time ASA opt-out"| F057
+ F001 -->|"applies init-time ATT wait timeout"| F058
+ F001 -->|"applies init-time debug flag"| F059
+ F002 -->|"deferred start once CMP consent confirmed"| F011
+
+ classDef sdkCore fill:#4C6EF5,color:#fff
+ classDef deepLinking fill:#E64980,color:#fff
+ classDef oneLinkAndGrowth fill:#7048E8,color:#fff
+```
+
+---
+
+## Section 3 β Dependency Table
+
+| Feature | Depends On | Note |
+|---------|-----------|------|
+| F-002 | F-001 | SDK session start only makes sense after init/options have been validated and passed to native |
+| F-011 | F-001 | TCF auto-consent collection requires manual-start init configuration |
+| F-011 | F-002 | TCF auto-consent defers the actual `startSDK()` call until CMP consent is confirmed |
+| F-014 | F-037 | Manual deep-link re-trigger forces the native SDK to re-run the same UDL resolution path |
+| F-021 | F-015 | iOS routes `setCustomerIdAndLogSession` to the identical native handler as plain `setCustomerUserId` |
+| F-022 | F-037 | Push-notification deep-link path config only matters once a payload reaches UDL resolution |
+| F-023 | F-025 | iOS validates against the sandbox/production endpoint set by the receipt-validation toggle |
+| F-023 | F-038 | V1 validation delivers its async result through the legacy purchase-validation callback |
+| F-024 | F-025 | iOS validates against the sandbox/production endpoint set by the receipt-validation toggle |
+| F-027 | F-028 | Invite-link generation needs a base OneLink ID configured at runtime |
+| F-027 | F-056 | Invite-link generation needs a base OneLink ID configured at init time (whichever wrote last wins) |
+| F-028 | F-056 | Both setters write the same native OneLink-ID property β last write wins |
+| F-029 | F-027 | iOS cross-promotion reuses the same invite-URL generator helper as invite-link generation |
+| F-031 | F-022 | Push notification data handling resolves deep links using the registered JSON key-path |
+| F-035 | F-001 | Conversion data delivery is gated by SDK init/start having registered the listener |
+| F-036 | F-001 | App-open attribution delivery is gated by SDK init/start having registered the listener |
+| F-037 | F-001 | UDL listener/delegate registration is gated by the UDL flag set during init |
+| F-037 | F-039 | UDL resolution on iOS is fed by the native URL-scheme/Universal-Link/Scene entry points |
+| F-037 | F-040 | UDL resolution on Android is fed by the new-intent forwarding entry point |
+| F-048 | F-001 | Plugin metadata is reported inline as part of the native `initSdk` call |
+| F-049 | F-051 | Android `configure()` requires the validation-result listener object as a constructor param |
+| F-049 | F-052 | iOS `configure()` assigns the purchase-revenue delegate that the combined callback depends on |
+| F-049 | F-054 | Purchase Connector only compiles/registers when the build-time opt-in is enabled |
+| F-050 | F-049 | StoreKit version is packed into the shared `configure()` payload owned by F-049 |
+| F-051 | F-049 | Android validation listeners only receive events once `configure()`/observation has started |
+| F-052 | F-049 | iOS combined validation callback relies on the delegate wired during `configure()` |
+| F-053 | F-049 | Data models are payload shapes exchanged only through the configured connector |
+| F-053 | F-051 | Data models are referenced exclusively from the Android validation-result listener models |
+| F-055 | F-049 | Guard exists specifically to catch use of Purchase Connector APIs before `configure()` runs |
+| F-056 | F-028 | Both setters write the same native OneLink-ID property β last write wins |
+| F-057 | F-001 | ASA opt-out is an init-time option validated/applied inside `initSdk` |
+| F-058 | F-001 | ATT wait timeout is an init-time option validated/applied inside `initSdk` |
+| F-059 | F-001 | Debug logging is an init-time option validated/applied inside `initSdk` |
diff --git a/internal-docs/features/F-001-sdk-initialization.md b/internal-docs/features/F-001-sdk-initialization.md
new file mode 100644
index 00000000..39a3a68a
--- /dev/null
+++ b/internal-docs/features/F-001-sdk-initialization.md
@@ -0,0 +1,79 @@
+---
+id: F-001
+name: SDK Initialization & Options Validation
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+This is the entry point that wires the Flutter app's dev key, app ID and startup flags into the native AppsFlyer SDK. Without it, no other AppsFlyer API works: no attribution, no events, no deep linking. The Dart-side validation (`_validateAFOptions` / `_validateMapOptions`) catches misconfiguration early (missing dev key, malformed iOS numeric App Store ID) via `assert`s, and decides whether the SDK auto-starts or waits for an explicit `startSDK()` call (F-002). It also stamps the plugin's identity (`Plugin.FLUTTER` / `AFSDKPluginFlutter`) onto the native SDK so AppsFlyer's backend can attribute traffic to the Flutter wrapper.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called once by the host app immediately after constructing `AppsflyerSdk(options)`, typically in `main()` before `runApp()`. Runs whenever `initSdk()` is invoked, regardless of whether `AppsFlyerOptions` (typed) or a raw `Map` was passed to the factory constructor.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk(options) factory [lib/src/appsflyer_sdk.dart]
+ β AppsflyerSdk.private(...) [lib/src/appsflyer_sdk.dart]
+AppsflyerSdk.initSdk({registerConversionDataCallback, registerOnAppOpenAttributionCallback, registerOnDeepLinkingCallback})
+ β _validateAFOptions(AppsFlyerOptions) | _validateMapOptions(Map) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("initSdk", validatedOptions)
+ β Android: AppsflyerSdkPlugin.onMethodCall("initSdk") β initSdk(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().init(afDevKey, gcdListener, mContext)
+ β instance.start(activity) [only if isManualStartMode == false]
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("initSdk") β initSdkWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [AppsFlyerLib shared].appsFlyerDevKey / .appleAppID / .isDebug = ...
+ β [[AppsFlyerLib shared] start] [only if manualStart == NO]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `initSdk`, `_validateAFOptions`, `_validateMapOptions` β validation + MethodChannel dispatch |
+| `lib/src/appsflyer_options.dart` | `AppsFlyerOptions` typed config model (devKey, appId, ATT wait time, manualStart, etc.) |
+| `lib/src/appsflyer_constants.dart` | String keys shared across Dart/native (`AF_DEV_KEY`, `AF_APP_Id`, `AF_MANUAL_START`, `AF_GCD`, `AF_UDL`, `PLUGIN_VERSION`) |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `initSdk(call, result)` β native Android init, conditional auto-start |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | Native Android mirror of the Dart string keys |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `initSdkWithCall:result:` β native iOS init, conditional auto-start |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/include/appsflyer_sdk/AppsflyerSdkPlugin.h` | `#define` string keys (`afDevKey`, `afAppId`, `afManualStart`, β¦) and `kAppsFlyerPluginVersion` |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `afDevKey` (String, required), `appId` (String, required on iOS β validated against `^\d{8,11}$`), `showDebug` (bool), `manualStart` (bool), `timeToWaitForATTUserAuthorization` (double, iOS only), `disableAdvertisingIdentifier` (bool), `disableCollectASA` (bool, iOS only), `appInviteOneLink` (String?), plus derived flags `GCD`/`UDL` computed from the `registerConversionDataCallback` / `registerOnAppOpenAttributionCallback` / `registerOnDeepLinkingCallback` parameters |
+| **Output** | Native SDK instance initialized and, unless `manualStart: true`, started; Android returns `"success"` string to Dart, iOS returns `{"status": "OK"}`. Neither is currently exposed to the caller since `initSdk()`'s returned `Future` is rarely awaited for its value. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check initSdk call` (line 93) constructs `AppsflyerSdk.private(...)` with `mapOptions` and asserts the mocked channel receives `initSdk`. This exercises `_validateMapOptions` end-to-end but does not assert on the resulting validated map's contents, and does not cover `_validateAFOptions` (the typed `AppsFlyerOptions` path) or the iOS App ID regex / ATT-wait-time assertions at all β those run only under `Platform.isIOS`, which the Dart test environment does not satisfy.
+
+---
+
+## Known Limitations
+- Validation uses Dart `assert()`, which is stripped in release/profile builds β a missing `afDevKey` or malformed iOS `appId` will silently pass validation in release mode and only fail (or silently misbehave) once it reaches native code.
+- The plugin version string is duplicated in three places and has drifted: Dart `AppsflyerConstants.PLUGIN_VERSION = "6.17.9"` (`lib/src/appsflyer_constants.dart`) vs. Android `AppsFlyerConstants.PLUGIN_VERSION = "6.18.0"` and iOS `kAppsFlyerPluginVersion = "6.18.0"` (matching `pubspec.yaml`'s `6.18.0`). The value reported to AppsFlyer's backend via `PluginInfo`/`setPluginInfoWith:` therefore differs from what `getVersionNumber()` (F-003) returns to the app.
+- `disableCollectASA` and `timeToWaitForATTUserAuthorization` are only read/applied on iOS; on Android these options are silently ignored (no assertion or warning).
+- Android's `initSdk` calls `result.success("success")` unconditionally at the end, even though `setDisableAdvertisingIdentifiers`, `subscribeForDeepLink`, etc. earlier in the method have no error handling β a native exception before that line surfaces to Flutter only as a generic platform exception, not one of the plugin's own error codes.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-002-sdk-start.md b/internal-docs/features/F-002-sdk-start.md
new file mode 100644
index 00000000..e4d4b554
--- /dev/null
+++ b/internal-docs/features/F-002-sdk-start.md
@@ -0,0 +1,79 @@
+---
+id: F-002
+name: SDK Start (auto/manual + result handler)
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: ["F-001"]
+---
+
+## Business Purpose
+When `manualStart: true` is set on `AppsFlyerOptions` (F-001), the native SDK is initialized but deliberately does **not** begin sending sessions/attribution requests β this lets the host app gate the first network call behind consent collection (see F-011/F-012) or other startup preconditions. `startSDK()` is the trigger that actually opens the session. Without it, apps using manual-start mode would never attribute installs or sessions. The optional `onSuccess`/`onError` handler variant lets the app know definitively whether the first session request succeeded, which matters for CMP/consent flows that need to confirm the SDK is live before proceeding.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called explicitly by the host app after `initSdk()` when `manualStart: true` was configured. Also implicitly satisfied automatically inside `initSdk`/`initSdkWithCall:` on both platforms when `manualStart` is `false` (the default), meaning most apps never call `startSDK()` directly.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.startSDK({onSuccess, onError}) [lib/src/appsflyer_sdk.dart]
+ β guards on _isSdkStarted (no-op if already started)
+ β if onSuccess/onError provided:
+ _methodChannel.setMethodCallHandler(...) // listens for native "onSuccess"/"onError"
+ _methodChannel.invokeMethod('startSDKwithHandler')
+ β Android: AppsflyerSdkPlugin.startSDKwithHandler(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().start(activity, null, AppsFlyerRequestListener)
+ β onSuccess()/onError() β mMethodChannel.invokeMethod("onSuccess"|"onError")
+ β iOS: AppsflyerSdkPlugin.startSDKwithHandler:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] startWithCompletionHandler:^(...)]
+ β [_methodChannel invokeMethod:@"onSuccess"|@"onError" ...]
+ β else:
+ _methodChannel.invokeMethod('startSDK')
+ β Android: AppsflyerSdkPlugin.startSDK(call, result) β AppsFlyerLib.getInstance().start(activity)
+ β iOS: AppsflyerSdkPlugin.startSDK:result: β [[AppsFlyerLib shared] start]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `startSDK()` β guards double-start via `_isSdkStarted`, chooses handler vs. plain path |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `startSDK`, `startSDKwithHandler` β native start, posts `onSuccess`/`onError` back on the UI thread |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `startSDK:result:`, `startSDKwithHandler:result:` β native start, dispatches completion handler results on main queue |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | Optional `RequestSuccessListener onSuccess` and `RequestErrorListener onError` Dart callbacks; no other parameters |
+| **Output** | No return value from `startSDK()` itself (`void`). If a handler was supplied, the native side invokes `onSuccess` with no arguments, or `onError(int errorCode, String errorMessage)` back through the Dart `MethodChannel.setMethodCallHandler`, after which the handler is torn down (`setMethodCallHandler(null)`) so it fires only once. |
+
+---
+
+## Tests
+No dedicated test found. `test/appsflyer_sdk_test.dart` covers `initSdk`, `setHost`, `logEvent`, etc., but has no test invoking `instance.startSDK(...)` in either its handler or plain form, and no test for the `_isSdkStarted` double-start guard.
+
+---
+
+## Known Limitations
+- `_isSdkStarted` is set to `true` as a side effect of `initSdk()` whenever `manualStart == false` (auto-start mode). If the host app then also calls `startSDK()` "just in case," the Dart guard silently no-ops it β this is correct behavior but is easy to misread as a bug when debugging why a manually-added `startSDK()` call appears to do nothing.
+- If `startSDK()` is called with a handler and the native side never calls back (e.g. process death, or an unexpected exception path), the Dart method handler is never cleared and `_isSdkStarted` remains `true` forever, permanently blocking any future `startSDK()` call for that app session.
+- On the `default` branch of the Android `setMethodCallHandler` switch (i.e. an unrecognized method name arrives), the Dart code resets `_isSdkStarted = false`, which would allow a subsequent `startSDK()` call to fire a second native `start()` β this branch is not currently exercised by any real native call and appears to be defensive/dead code.
+- iOS's `startSDKwithHandler:` also registers a `UIApplicationDidBecomeActiveNotification` observer (`appDidBecomeActive`) as a side effect of the plain `startSDK:` path but not from within `startSDKwithHandler:` itself β foreground-resume auto-restart behavior differs subtly between the two start paths.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F002["F-002 Β· SDK Start"]:::sdkCore -->|"only meaningful when manualStart is set during"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-003-sdk-plugin-version-retrieval.md b/internal-docs/features/F-003-sdk-plugin-version-retrieval.md
new file mode 100644
index 00000000..5b031235
--- /dev/null
+++ b/internal-docs/features/F-003-sdk-plugin-version-retrieval.md
@@ -0,0 +1,72 @@
+---
+id: F-003
+name: SDK/Plugin Version Retrieval
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Support and QA need a reliable way to answer "which native AppsFlyer SDK build, and which Flutter plugin build, is actually running in this app?" `getSDKVersion()` surfaces the native SDK's own version string (useful for diagnosing SDK-side bugs against AppsFlyer's release notes), while `getVersionNumber()` surfaces the Flutter plugin wrapper's own version. Without these, bug reports and support tickets would rely on the app's `pubspec.yaml`/podspec pin, which does not confirm what was actually compiled into the running binary.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called on demand by host app code β typically diagnostic/support tooling, debug menus, or logging at startup.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.getSDKVersion() [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("getSDKVersion")
+ β Android: AppsflyerSdkPlugin.onMethodCall("getSDKVersion") β getSdkVersion(result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().getSdkVersion()
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("getSDKVersion") β getSDKVersion:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] getSDKVersion]
+
+AppsflyerSdk.getVersionNumber() [lib/src/appsflyer_sdk.dart]
+ β returns AppsflyerConstants.PLUGIN_VERSION (pure Dart constant, no channel call) [lib/src/appsflyer_constants.dart]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `getSDKVersion()` (async, native round-trip), `getVersionNumber()` (sync, local constant) |
+| `lib/src/appsflyer_constants.dart` | `PLUGIN_VERSION` constant returned by `getVersionNumber()` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `getSdkVersion(result)` β proxies `AppsFlyerLib.getInstance().getSdkVersion()` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `getSDKVersion:result:` β proxies `[AppsFlyerLib shared] getSDKVersion]` |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | None |
+| **Output** | `getSDKVersion()` β `Future` β the native AppsFlyer SDK's own version string. `getVersionNumber()` β `String` β the Flutter plugin's hardcoded version constant (synchronous, no native call). |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check getSDKVersion call` (line 192) asserts the mocked channel receives `getSDKVersion`. No test exists for `getVersionNumber()` (trivial, but untested).
+
+---
+
+## Known Limitations
+- `AppsflyerConstants.PLUGIN_VERSION` in Dart (`lib/src/appsflyer_constants.dart`) is hardcoded to `"6.17.9"`, while `pubspec.yaml`'s package version, Android's `AppsFlyerConstants.PLUGIN_VERSION`, and iOS's `kAppsFlyerPluginVersion` are all `"6.18.0"`. `getVersionNumber()` therefore returns a stale value one release behind the actual plugin version and the value the native layer reports upstream to AppsFlyer via `setPluginInfo`/`setPluginInfoWith:` (see F-001). This is a manual-bump constant with no single source of truth or CI check tying it to `pubspec.yaml`.
+- `getVersionNumber()` reports the *plugin's* version, not the native SDK's version β the naming similarity to `getSDKVersion()` is a common source of confusion for integrators.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F003["F-003 Β· SDK/Plugin Version Retrieval"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-004-in-app-event-logging.md b/internal-docs/features/F-004-in-app-event-logging.md
new file mode 100644
index 00000000..8cf260b3
--- /dev/null
+++ b/internal-docs/features/F-004-in-app-event-logging.md
@@ -0,0 +1,73 @@
+---
+id: F-004
+name: In-App Event Logging
+type: eventsAndRevenue
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+AppsFlyer's attribution model can only compute ROI (Return on Investment) and LTV (Lifetime Value) for media sources if the app reports what users actually *do* after install β purchases, tutorial completions, level-ups, subscriptions, etc. `logEvent` is the single funnel through which every custom in-app event (a name plus an arbitrary value map) reaches AppsFlyer's backend and is joined to the installing campaign/media-source. Without it, install attribution would exist in isolation with no downstream engagement or monetization signal, making campaign performance comparison and LTV/ROI reporting impossible.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app at any point after the SDK is initialized, whenever a business-significant in-app action occurs (e.g. purchase, level completion, tutorial finish, subscription).
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.logEvent(eventName, eventValues) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("logEvent", {'eventName': ..., 'eventValues': ...})
+ β Android: AppsflyerSdkPlugin.onMethodCall("logEvent") β logEvent(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().logEvent(mContext, eventName, eventValues)
+ β result.success(true)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("logEvent") β logEventWithCall:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] logEvent:eventName withValues:eventValues]
+ β result(@YES)
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `logEvent(String eventName, Map? eventValues)` β Dart public API, returns `Future` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logEvent(MethodCall, Result)` β reads `AF_EVENT_NAME`/`AF_EVENT_VALUES` args, forwards to `AppsFlyerLib.getInstance().logEvent(mContext, eventName, eventValues)`, always returns `result.success(true)` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logEventWithCall:result:` β reads `eventName`/`eventValues` (normalizes `NSNull` to `nil`), forwards to `[[AppsFlyerLib shared] logEvent:withValues:]`, always returns `result(@YES)`; comment `//TODO: Add callback handler` marks that no completion callback is wired |
+| `doc/InAppEvents.md` | Public integration guide with usage example |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `eventName` (String, required β AppsFlyer docs recommend β€45 chars or the event is dropped from the dashboard but still visible in raw data); `eventValues` (Map, nullable β arbitrary event parameters, e.g. `af_revenue`, `af_content_id`) |
+| **Output** | `Future` β on both platforms this resolves to `true` unconditionally once the native SDK call is *dispatched*; it does not reflect whether the event was actually delivered to/accepted by AppsFlyer's backend (no listener/callback is wired on either platform) |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check logEvent call` (line 115) awaits `logEvent("eventName", {"key": "val"})` against a mocked channel and asserts the channel receives the `logEvent` invocation; it only exercises the Dart-to-channel dispatch, not native behavior or the actual return value semantics.
+
+---
+
+## Known Limitations
+- **No delivery confirmation on either platform**: both native handlers call the fire-and-forget overload of the AppsFlyer SDK's `logEvent` (no `AppsFlyerRequestListener`/completion block) and immediately return `true`/`@YES`. A caller awaiting `logEvent()` gets no signal about whether the event actually reached AppsFlyer β the returned boolean only reflects "the method call was processed," not "the event was sent successfully."
+- iOS explicitly documents this gap in-code: `//TODO: Add callback handler` in `logEventWithCall:result:`.
+- No client-side validation of the 45-character event-name limit; events with longer names still get accepted by the plugin and are silently excluded from the AppsFlyer dashboard (only visible via raw data/Pull/Push APIs), per `doc/InAppEvents.md`.
+- `eventValues` accepts an untyped `Map`, so type mismatches (e.g. non-JSON-serializable values) are only caught when the native SDK attempts to serialize the payload, not at the Dart call site.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F004["F-004 Β· In-App Event Logging"]:::eventsAndRevenue
+ classDef eventsAndRevenue fill:#12B886,color:#fff
+```
diff --git a/internal-docs/features/F-005-ad-revenue-logging.md b/internal-docs/features/F-005-ad-revenue-logging.md
new file mode 100644
index 00000000..d3df7818
--- /dev/null
+++ b/internal-docs/features/F-005-ad-revenue-logging.md
@@ -0,0 +1,79 @@
+---
+id: F-005
+name: Ad Revenue Logging
+type: eventsAndRevenue
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Apps that monetize through in-app advertising (rather than, or in addition to, direct purchases) need their ad-impression revenue attributed back to the campaigns/media sources that drove the installs β otherwise ROI/LTV reporting only sees purchase revenue and dramatically understates (or misses entirely) the true value of ad-monetized user cohorts. `logAdRevenue` reports a single ad-revenue event (network, mediation platform, currency, amount, optional extra params) to AppsFlyer so that ad monetization can be joined to install attribution the same way in-app purchase events are (see F-004). Removing it would blind AppsFlyer's dashboards to any revenue generated purely through ad impressions/clicks.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app whenever a mediation SDK (AdMob, AppLovin MAX, ironSource, Unity, etc.) reports a paid ad impression/click, typically from within that mediation SDK's own revenue-paid callback.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.logAdRevenue(AdRevenueData) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("logAdRevenue", adRevenueData.toMap())
+ β Android: AppsflyerSdkPlugin.onMethodCall("logAdRevenue") β logAdRevenue(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β MediationNetwork.valueOf(mediationNetworkString.toUpperCase(Locale.ENGLISH))
+ β new AFAdRevenueData(monetizationNetwork, mediationNetwork, currencyIso4217Code, revenue)
+ β AppsFlyerLib.getInstance().logAdRevenue(adRevenueData, additionalParameters)
+ β result.success(true) | result.error(...) on invalid/unexpected input
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("logAdRevenue") β logAdRevenue:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β getEnumValueFromString: maps the Dart enum's string value to AppsFlyerAdRevenueMediationNetworkType
+ β [[AFAdRevenueData alloc] initWithMonetizationNetwork:mediationNetwork:currencyIso4217Code:eventRevenue:]
+ β [[AppsFlyerLib shared] logAdRevenue:additionalParameters:]
+ β (no result(...) call on the success path; result(...) is only invoked on error)
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `logAdRevenue(AdRevenueData)` β Dart public API, `void`, serializes via `adRevenueData.toMap()` |
+| `lib/src/appsflyer_ad_revenue_data.dart` | `AdRevenueData` model: `monetizationNetwork`, `mediationNetwork` (String), `currencyIso4217Code`, `revenue` (double), optional `additionalParameters` |
+| `lib/src/appsflyer_constants.dart` | `AFMediationNetwork` enum with a `.value` getter mapping each case (e.g. `applovinMax`) to the exact lowercase/snake_case string (`"applovin_max"`) both native sides expect |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `logAdRevenue(MethodCall, Result)` β validates required args via `requireNonNullArgument`, converts the mediation-network string to the native `MediationNetwork` enum via `.valueOf(...toUpperCase())`, builds `AFAdRevenueData`, calls `AppsFlyerLib.getInstance().logAdRevenue(...)` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `logAdRevenue:result:` and `getEnumValueFromString:` β validates required args, maps the mediation-network string to `AppsFlyerAdRevenueMediationNetworkType` via an explicit `NSDictionary` lookup table, builds `AFAdRevenueData`, calls `[[AppsFlyerLib shared] logAdRevenue:additionalParameters:]` |
+| `doc/API.md` | `logAdRevenue` / `AdRevenueData` / `AFMediationNetwork` public documentation and usage example |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `monetizationNetwork` (String, required β the ad network the impression came from, e.g. "GoogleAdMob"); `mediationNetwork` (String, required β must equal one of `AFMediationNetwork.value`'s outputs, e.g. `"applovin_max"`); `currencyIso4217Code` (String, required); `revenue` (double, required); `additionalParameters` (Map, optional) |
+| **Output** | Android: `Future` (unused by the `void` Dart method) resolving `result.success(true)` on success, or `result.error("INVALID_ARGUMENT_PROVIDED", ...)` for a missing/unrecognized field, or `result.error("UNEXPECTED_ERROR", ...)` for any other throwable. iOS: `result(...)` is only ever invoked on the error paths (`FlutterError` with codes such as `NULL_MONETIZATION_NETWORK`, `INVALID_MEDIATION_NETWORK`, `UNEXPECTED_ERROR`); on success the method returns without calling `result` at all. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check logAdRevenue call` (line 296) constructs an `AdRevenueData` with `AFMediationNetwork.applovinMax.value`, calls `logAdRevenue`, and asserts the mocked channel receives `logAdRevenue` with `mediationNetwork == 'applovin_max'`. This exercises only the Dart-to-channel dispatch and the enum-to-string mapping; it does not exercise either native handler's mediation-network parsing/validation logic.
+
+---
+
+## Known Limitations
+- **String-based mediation network mapping is duplicated three times** (Dart `AFMediationNetwork.value`, Android's `MediationNetwork.valueOf(...toUpperCase())`, iOS's hand-written `NSDictionary` in `getEnumValueFromString:`) with no shared source of truth β adding a new mediation network requires updating all three in lockstep, and a mismatch (e.g. a typo in one map) fails silently as an "unsupported network" error at runtime rather than a compile-time error.
+- **iOS never resolves the Flutter result on success**: in `logAdRevenue:result:`, the success path calls `[[AppsFlyerLib shared] logAdRevenue:additionalParameters:]` and returns without ever calling `result(...)`. Since the Dart-side `logAdRevenue` is `void` and does not await a result, this is silent to callers today, but it means the platform channel's pending reply is simply never resolved on the happy path β asymmetric with Android, which always calls `result.success(true)`.
+- Android's mediation-network parsing uses `.toUpperCase(Locale.ENGLISH)` then `MediationNetwork.valueOf(...)`; any string that doesn't exactly match a native enum constant after upper-casing (e.g. an unexpected value from a future `AFMediationNetwork` addition) throws `IllegalArgumentException`, caught and surfaced as `INVALID_ARGUMENT_PROVIDED` β but only after the Dart caller has already committed to that string via the shared enum, so failures depend on the plugin's native SDK dependency version staying in sync with `AFMediationNetwork`.
+- No compile-time guarantee that `AdRevenueData.mediationNetwork` (a plain `String`) was actually built from `AFMediationNetwork.value` β passing an arbitrary string compiles fine and only fails at the native layer.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F005["F-005 Β· Ad Revenue Logging"]:::eventsAndRevenue
+ classDef eventsAndRevenue fill:#12B886,color:#fff
+```
diff --git a/internal-docs/features/F-006-custom-host-configuration.md b/internal-docs/features/F-006-custom-host-configuration.md
new file mode 100644
index 00000000..80a1b86f
--- /dev/null
+++ b/internal-docs/features/F-006-custom-host-configuration.md
@@ -0,0 +1,75 @@
+---
+id: F-006
+name: Custom Host Configuration
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Enterprises operating in regulated markets (e.g. China) or behind private network/CDN setups need the AppsFlyer SDK to send its HTTPS traffic to a non-default host. `setHost` lets the integrator redirect the SDK's network calls to a custom domain/prefix; `getHostName`/`getHostPrefix` let the app (or diagnostics tooling) confirm what is currently configured. Without this, apps requiring a custom collection endpoint could not integrate AppsFlyer at all in those environments.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app before/around SDK start, whenever the default AppsFlyer collection host must be overridden. `getHostName`/`getHostPrefix` are called on demand (e.g. debug screens) to read back the current configuration.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setHost(hostPrefix, hostName) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setHost", {hostPrefix, hostName})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setHost") β setHost(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setHost(hostPrefix, hostName)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setHost") β setHost:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] setHost:hostName withHostPrefix:hostPrefix]
+
+AppsflyerSdk.getHostName() / getHostPrefix()
+ β _methodChannel.invokeMethod("getHostName" | "getHostPrefix")
+ β Android: getHostName(result) / getHostPrefix(result) β AppsFlyerLib.getInstance().getHostName()/getHostPrefix()
+ β iOS: getHostName:result: / getHostPrefix:result: β [[AppsFlyerLib shared] host] / [[AppsFlyerLib shared] hostPrefix]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setHost`, `getHostName`, `getHostPrefix` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setHost`, `getHostName`, `getHostPrefix` native handlers |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsFlyerConstants.java` | `AF_HOST_PREFIX`, `AF_HOST_NAME` argument key constants |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setHost:result:`, `getHostName:result:`, `getHostPrefix:result:` native handlers |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `setHost`: `hostPrefix` (String), `hostName` (String). `getHostName`/`getHostPrefix`: none. |
+| **Output** | `setHost` β `void` (fire-and-forget). `getHostName()`/`getHostPrefix()` β `Future` reflecting the currently configured values. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setHost call` (line 121) asserts the channel receives `setHost` with `hostPrefix`/`hostName` arguments; `check getHostPrefix call` (line 220) and `check getHostName call` (line 226) assert the corresponding method names are invoked. No test asserts the actual return value flowing back from a (mocked) native host name/prefix.
+
+---
+
+## Known Limitations
+- **Android bug**: `AppsflyerSdkPlugin.setHost(call, result)` never calls `result.success(...)` or `result.error(...)` β every other handler in the file does. Because Dart's `setHost()` is `void` and does not await the returned `Future`, this is currently harmless to callers, but it means the platform channel's pending reply for that invocation is left unresolved, unlike all other methods in this plugin, and would surface as a bug if a future refactor made `setHost` return/await a value.
+- No input validation on `hostPrefix`/`hostName` on either platform β an empty string or malformed host is passed straight to the native SDK, which may fail silently or send traffic nowhere.
+- Must be called before the SDK actually establishes its first network connection to take effect; calling it after `startSDK()`/auto-start has already fired a request may be too late β this ordering constraint is not enforced by the Dart or native code.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F006["F-006 Β· Custom Host Configuration"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-007-device-id-collection-optout.md b/internal-docs/features/F-007-device-id-collection-optout.md
new file mode 100644
index 00000000..c09617c3
--- /dev/null
+++ b/internal-docs/features/F-007-device-id-collection-optout.md
@@ -0,0 +1,71 @@
+---
+id: F-007
+name: Device ID Collection Opt-out (IMEI/Android ID)
+type: sdkCore
+platform: android
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Google Play policy prohibits apps that bundle Google Play Services from collecting IMEI or Android ID for advertising/attribution purposes; only apps without Play Services are allowed to rely on these identifiers as a fallback. `setCollectIMEI`/`setCollectAndroidId` let a Play-Services-enabled app explicitly opt out of this collection so it stays compliant, while apps without Play Services can leave it enabled as their only device-level identifier fallback. Getting this wrong risks Play Store policy violations and app rejection/removal.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app during startup configuration, before or around SDK init, whenever the app needs to explicitly declare its IMEI/Android ID collection posture (typically apps that ship with Google Play Services present).
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setCollectIMEI(isCollect) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setCollectIMEI", {'isCollect': isCollect})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setCollectIMEI") β setCollectIMEI(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setCollectIMEI(isCollect)
+
+AppsflyerSdk.setCollectAndroidId(isCollect) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setCollectAndroidId", {'isCollect': isCollect})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setCollectAndroidId") β setCollectAndroidId(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setCollectAndroidID(isCollect)
+```
+No iOS branch exists for either method name in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:` β on iOS these calls fall through to `result(FlutterMethodNotImplemented)`.
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setCollectIMEI(bool)`, `setCollectAndroidId(bool)` β platform-agnostic Dart API surface (no `Platform.isAndroid` guard) |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCollectIMEI`, `setCollectAndroidId` native handlers |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `isCollect` (bool) β `true` keeps collection enabled (default SDK behavior), `false` opts out. |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setCollectIMEI call` (line 232) and `check setCollectAndroidId call` (line 238) assert the mocked channel receives the respective method names. Tests run in the Dart test harness only, so they cannot and do not distinguish Android vs. iOS native behavior.
+
+---
+
+## Known Limitations
+- **Android-only**: there is no corresponding native implementation on iOS (concept doesn't apply β IMEI/Android ID are Android-specific identifiers). Calling these methods from a Flutter app running on iOS results in a `MissingPluginException`/`FlutterMethodNotImplemented` at the native layer, since the Dart API has no platform guard and will happily invoke the channel method regardless of `Platform.isIOS`.
+- No compile-time or runtime warning in the Dart layer indicates these are Android-only; integrators must consult documentation (or this catalog) to learn that.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F007["F-007 Β· Device ID Collection Opt-out"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-008-manual-imei-android-id-override.md b/internal-docs/features/F-008-manual-imei-android-id-override.md
new file mode 100644
index 00000000..35869e17
--- /dev/null
+++ b/internal-docs/features/F-008-manual-imei-android-id-override.md
@@ -0,0 +1,71 @@
+---
+id: F-008
+name: Manual IMEI/Android ID Override
+type: sdkCore
+platform: android
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Some apps already collect IMEI/Android ID themselves (e.g. via a legacy device-management SDK) and want AppsFlyer to reuse those values rather than re-reading them independently, or need to supply a value in contexts where the SDK's own read would fail (e.g. restricted permission states). `setImeiData`/`setAndroidIdData` let the host app hand these identifiers to the SDK directly instead of relying on its automatic collection (F-007 governs whether that automatic collection happens at all).
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app during startup configuration when it already holds IMEI/Android ID values it wants to feed to AppsFlyer, in place of the SDK's own device-level collection.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setImeiData(imei) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setImeiData", {'imei': imei})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setImeiData") β setImeiData(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setImeiData(imei)
+
+AppsflyerSdk.setAndroidIdData(androidId) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setAndroidIdData", {'androidId': androidId})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setAndroidIdData") β setAndroidIdData(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setAndroidIdData(androidId)
+```
+No iOS branch exists for either method name in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:`.
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setImeiData(String)`, `setAndroidIdData(String)` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setImeiData`, `setAndroidIdData` native handlers |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `setImeiData`: `imei` (String). `setAndroidIdData`: `androidId` (String). |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setImeiData call` (line 272) and `check setAndroidIdData call` (line 278) assert the mocked channel receives the respective method names. No assertion on the argument values actually reaching native code (only channel dispatch is exercised, per the test's mock architecture).
+
+---
+
+## Known Limitations
+- **Android-only**: no iOS implementation (IMEI/Android ID are not applicable identifiers on iOS). Same `MissingPluginException`/`FlutterMethodNotImplemented` risk as F-007 if called on iOS, since the Dart API is not platform-guarded.
+- No format/length validation of the `imei`/`androidId` strings before they are handed to the native SDK β a malformed value would only surface as a data-quality problem downstream in AppsFlyer's reporting, not as a client-side error.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F008["F-008 Β· Manual IMEI/Android ID Override"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-009-min-time-between-sessions.md b/internal-docs/features/F-009-min-time-between-sessions.md
new file mode 100644
index 00000000..3e3eb6b0
--- /dev/null
+++ b/internal-docs/features/F-009-min-time-between-sessions.md
@@ -0,0 +1,69 @@
+---
+id: F-009
+name: Minimum Time Between Sessions
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+By default AppsFlyer starts a new session whenever the app returns to the foreground after being backgrounded, using the SDK's built-in threshold. Apps with unusual foreground/background patterns (e.g. quick task-switching flows, widget-driven relaunches) can get inflated session counts that distort engagement metrics. `setMinTimeBetweenSessions` lets the app widen (or narrow) that threshold so relaunches within the configured window are folded into the current session instead of counted as a new one, keeping session-based KPIs meaningful.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app during startup configuration, before or shortly after SDK init, whenever the default session-splitting threshold needs to be overridden.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setMinTimeBetweenSessions(seconds) [lib/src/appsflyer_sdk.dart]
+ β assert(seconds >= 0)
+ β _methodChannel.invokeMethod("setMinTimeBetweenSessions", {'seconds': seconds})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setMinTimeBetweenSessions") β setMinTimeBetweenSessions(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setMinTimeBetweenSessions(seconds)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setMinTimeBetweenSessions") β setMinTimeBetweenSessions:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [AppsFlyerLib shared].minTimeBetweenSessions = seconds
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setMinTimeBetweenSessions(int)` β asserts non-negative seconds, dispatches to channel |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setMinTimeBetweenSessions` native handler |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setMinTimeBetweenSessions:result:` native handler (direct property assignment) |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `seconds` (int, must be `>= 0` per Dart `assert`) |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setMinTimeBetweenSessions call` (line 214) asserts the mocked channel receives `setMinTimeBetweenSessions`. The negative-seconds `assert` guard is not covered by any test.
+
+---
+
+## Known Limitations
+- The `seconds >= 0` guard is a Dart `assert()`, which is stripped in release/profile builds β a negative value passed in a release build reaches native code unchecked, where behavior is whatever the native SDK does with a negative threshold (undocumented in this repo).
+- No upper-bound validation β an unreasonably large value (e.g. `Duration` misused as seconds) is not caught client-side.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F009["F-009 Β· Minimum Time Between Sessions"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-010-currency-code-setting.md b/internal-docs/features/F-010-currency-code-setting.md
new file mode 100644
index 00000000..4550b6d7
--- /dev/null
+++ b/internal-docs/features/F-010-currency-code-setting.md
@@ -0,0 +1,72 @@
+---
+id: F-010
+name: Currency Code Setting
+type: eventsAndRevenue
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+AppsFlyer's revenue analytics (ROI/LTV dashboards) need a consistent currency to normalize monetary values reported through in-app purchase/revenue events. Apps that sell in a currency other than the SDK's USD default must declare that currency once via `setCurrencyCode`, so every subsequent in-app event's monetary value is interpreted (and converted for reporting) correctly. Without it, revenue figures for non-USD apps would be misreported or misinterpreted at AppsFlyer's default currency assumption, corrupting revenue-based attribution and LTV comparisons across campaigns.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app once, typically at startup (before or after logging revenue-bearing events), whenever the app's transactions are denominated in a non-default (non-USD) currency.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setCurrencyCode(currencyCode) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setCurrencyCode", {'currencyCode': currencyCode})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setCurrencyCode") β setCurrencyCode(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setCurrencyCode(currencyCode)
+ β result.success(null)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setCurrencyCode") β setCurrencyCode:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] setCurrencyCode:currencyCode]
+ β result(nil)
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setCurrencyCode(String currencyCode)` β platform-agnostic Dart API, `void` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCurrencyCode(MethodCall, Result)` β forwards to `AppsFlyerLib.getInstance().setCurrencyCode(currencyCode)` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCurrencyCode:result:` β forwards to `[[AppsFlyerLib shared] setCurrencyCode:]` |
+| `doc/API.md` | Public documentation for `setCurrencyCode` |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `currencyCode` (String) β expected to be a 3-character ISO 4217 code (default is `"USD"` per the Dart doc comment) |
+| **Output** | `void` on the Dart side; both native handlers call `result(nil)`/`result.success(null)` unconditionally β there is no validation or error signal if an invalid/malformed currency code is passed |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setCurrencyCode call` (line 129) calls `setCurrencyCode("USD")` and asserts the mocked channel receives `setCurrencyCode` with `currencyCode: 'USD'`; exercises only the Dart-to-channel dispatch, not native validation (since none exists) or actual downstream effect on event currency conversion.
+
+---
+
+## Known Limitations
+- **No format validation anywhere in the plugin**: neither the Dart API, nor the Android handler, nor the iOS handler check that `currencyCode` is a valid 3-letter ISO 4217 code. Any string (empty, too long, lowercase, non-existent code) is passed straight through to the native SDK; whether the native SDK itself validates or silently ignores an invalid code is outside this plugin's code and undocumented here.
+- No API to read back the currently configured currency code β the plugin is write-only for this setting (unlike, e.g., `getHostName`/`getHostPrefix` for `setHost`).
+- No enforced ordering relative to `initSdk()`/`startSDK()` or relative to `logEvent`/`logAdRevenue` calls; if called after revenue events have already been logged, prior events may retain the previous (default `"USD"`) currency depending on native SDK behavior, which is not something this plugin layer controls or documents.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F010["F-010 Β· Currency Code Setting"]:::eventsAndRevenue
+ classDef eventsAndRevenue fill:#12B886,color:#fff
+```
diff --git a/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md b/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md
new file mode 100644
index 00000000..7bf6f923
--- /dev/null
+++ b/internal-docs/features/F-011-tcf-dma-automatic-consent-collection.md
@@ -0,0 +1,71 @@
+---
+id: F-011
+name: TCF/DMA Automatic Consent Collection
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: ["F-001", "F-002"]
+---
+
+## Business Purpose
+The EU Digital Markets Act (DMA) requires gatekeepers like Google to obtain and forward user consent data before certain attribution/advertising interactions can occur. Rather than forcing every integrator to manually read Consent Management Platform (CMP) state and pass it to AppsFlyer via F-012's API, `enableTCFDataCollection` lets the SDK read TCF v2.2-formatted consent strings directly out of `SharedPreferences` (Android) / `NSUserDefaults` (iOS) β wherever a TCF-compliant CMP already stores them β and attach that consent data to every outgoing event automatically. Without this, apps using a CMP would need to duplicate the CMP's consent state into AppsFlyer's manual consent API themselves.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app once, typically at startup before SDK init. Per `doc/DMA.md`, the documented integration pattern is: (1) call `enableTCFDataCollection(true)`, (2) initialize the SDK with `manualStart: true` (F-001), (3) let the CMP present its consent dialog if needed, (4) once the CMP confirms consent data is stored, call `startSDK()` (F-002) so the first network request already carries the CMP-collected consent.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.enableTCFDataCollection(shouldCollect) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeListMethod("enableTCFDataCollection", {'shouldCollect': shouldCollect})
+ β Android: AppsflyerSdkPlugin.onMethodCall("enableTCFDataCollection") β enableTCFDataCollection(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().enableTCFDataCollection(shouldCollect)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("enableTCFDataCollection") β enableTCFDataCollection:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] enableTCFDataCollection:shouldCollect]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `enableTCFDataCollection(bool)` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `enableTCFDataCollection` native handler |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `enableTCFDataCollection:result:` native handler |
+| `doc/DMA.md` | Integration guide documenting the required manual-start + CMP sequencing |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `shouldCollect` (bool) β `true` enables automatic TCF v2.2 string reads from platform storage. |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. Downstream effect is that TCF consent strings are attached to subsequent SDK network requests. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check enableTCFDataCollection call` (line 308) asserts the mocked channel receives `enableTCFDataCollection`. No test verifies the documented manual-start/CMP sequencing, and no test exists for the interaction between this flag and `startSDK()`/`initSdk()` timing.
+
+---
+
+## Known Limitations
+- Dart's `enableTCFDataCollection` calls `_methodChannel.invokeListMethod(...)` (list-typed channel invocation) even though neither native handler returns a list β Android's handler returns `result.success(null)` and iOS's returns `result(nil)`. This mismatched invocation method works today only because the return value is discarded (`void` method, result not awaited); it is a latent inconsistency versus every other setter in this file, which use plain `invokeMethod`.
+- The feature is purely a "read consent from storage" toggle β it does not validate that a TCF-compliant CMP is actually present or that the stored string is well-formed; if no CMP has written TCF data, the SDK simply finds nothing to read, with no error surfaced to the app.
+- Correct behavior depends entirely on the app following the documented ordering (manual start β CMP consent β `startSDK()`); calling `enableTCFDataCollection` after the SDK has already auto-started (default `manualStart: false`) may mean the first session/event already went out without consent data attached.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F011["F-011 Β· TCF/DMA Automatic Consent Collection"]:::sdkCore -->|"requires manualStart configured via"| F001["F-001 Β· SDK Initialization & Options Validation"]:::sdkCore
+ F011 -->|"deferred session start after CMP consent, via"| F002["F-002 Β· SDK Start"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md b/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md
new file mode 100644
index 00000000..84c962bf
--- /dev/null
+++ b/internal-docs/features/F-012-manual-gdpr-dma-consent-api.md
@@ -0,0 +1,83 @@
+---
+id: F-012
+name: Manual GDPR/DMA Consent API (V1 + V2)
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Apps that don't rely on a TCF-compatible CMP (F-011) still need a way to legally record and forward the user's GDPR/DMA consent decisions before AppsFlyer collects or uses their data. `setConsentData`/`setConsentDataV2` are the manual counterpart: the app itself determines whether GDPR applies and what the user consented to, then hands that decision to the SDK explicitly. `setConsentDataV2` is the current, more granular API (adds `hasConsentForAdStorage`, supports nullable "not yet decided" states); `setConsentData` is the deprecated V1 shape kept for backward compatibility. Getting this right is a legal-compliance requirement, not just a UX nicety β incorrect or missing consent forwarding can put the integrating company in violation of GDPR/DMA.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app once consent has been captured from the user (via its own consent UI), and per `doc/DMA.md`, ideally called *before* `initSdk()` (or at least before `startSDK()` when using manual-start mode) so the very first SDK network request already carries the correct consent state.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setConsentData(AppsFlyerConsent consentData) [DEPRECATED] [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod('setConsentData', {'consentData': consentData.toMap()})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setConsentData") β setConsentData(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β new AppsFlyerConsent.forGDPRUser(...) | AppsFlyerConsent.forNonGDPRUser()
+ β AppsFlyerLib.getInstance().setConsentData(consentData)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setConsentData") β setConsentData:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerConsent alloc] initForGDPRUserWith...] | initWithNonGDPRUser
+ β [[AppsFlyerLib shared] setConsentData:consentData]
+
+AppsflyerSdk.setConsentDataV2({isUserSubjectToGDPR, consentForDataUsage, consentForAdsPersonalization, hasConsentForAdStorage}) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod('setConsentDataV2', {...})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setConsentDataV2") β setConsentDataV2(call, result) β getAppsFlyerConsentFromCall(call) [android/.../AppsflyerSdkPlugin.java]
+ β new AppsFlyerConsent(isUserSubjectToGDPR, consentForDataUsage, consentForAdsPersonalization, hasConsentForAdStorage)
+ β AppsFlyerLib.getInstance().setConsentData(consent)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setConsentDataV2") β setConsentDataV2:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerConsent alloc] initWithIsUserSubjectToGDPR:...hasConsentForAdStorage:...]
+ β [[AppsFlyerLib shared] setConsentData:consentData]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_consent.dart` | `AppsFlyerConsent` model β `forGDPRUser`/`nonGDPRUser` factories, `toMap()` (used by deprecated V1 API only) |
+| `lib/src/appsflyer_sdk.dart` | `setConsentData` (`@Deprecated`), `setConsentDataV2` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setConsentData` (deprecated), `setConsentDataV2`, `getAppsFlyerConsentFromCall` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setConsentData:result:` (deprecated), `setConsentDataV2:result:` |
+| `doc/DMA.md` | Full integration guide for both the CMP-automatic (F-011) and manual (this feature) consent paths |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | V1 (`setConsentData`): `AppsFlyerConsent` object β `isUserSubjectToGDPR` (bool), `hasConsentForDataUsage` (bool), `hasConsentForAdsPersonalization` (bool). V2 (`setConsentDataV2`): four independently-nullable named bools β `isUserSubjectToGDPR`, `consentForDataUsage`, `consentForAdsPersonalization`, `hasConsentForAdStorage` β `null` explicitly means "not yet decided," distinct from `false`. |
+| **Output** | `void` for both β fire-and-forget on the Dart side. Android's `setConsentDataV2` wraps the native call in try/catch and returns a `CONSENT_ERROR` platform error to Dart on failure; iOS's V2 handler similarly catches `NSException` and returns a `CONSENT_ERROR` `FlutterError`. The deprecated V1 handlers on both platforms have no error handling. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` mocks `'setConsentData'` in its channel handler switch (line 56) but has **no test** that actually calls `instance.setConsentData(...)` or asserts on it β the mock case exists without a corresponding `test(...)` block. There is **no test at all**, mocked or otherwise, for `setConsentDataV2`, despite it being the currently recommended API per `doc/DMA.md`.
+
+---
+
+## Known Limitations
+- `setConsentData` (V1) is `@Deprecated('Use setConsentDataV2 instead')` in Dart, and `doc/DMA.md` explicitly flags it as deprecated, yet it remains fully wired end-to-end on both platforms with no runtime warning or removal timeline.
+- V1's `AppsFlyerConsent` model (`lib/src/appsflyer_consent.dart`) forces `hasConsentForDataUsage`/`hasConsentForAdsPersonalization` to non-null booleans, which cannot represent an explicit "user has not yet decided" state β this is precisely the gap V2's nullable parameters were introduced to close.
+- `setConsentDataV2` has zero test coverage despite being the actively recommended, DMA-critical API β a regression in its argument marshaling (e.g. a renamed key on one platform) would not be caught by the existing test suite.
+- Both consent APIs are order-sensitive relative to `initSdk()`/`startSDK()` (must be called first to affect the initial request), but neither the Dart API nor either native handler enforces or warns about this ordering.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F012["F-012 Β· Manual GDPR/DMA Consent API (V1 + V2)"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-013-user-anonymization.md b/internal-docs/features/F-013-user-anonymization.md
new file mode 100644
index 00000000..725b3ec6
--- /dev/null
+++ b/internal-docs/features/F-013-user-anonymization.md
@@ -0,0 +1,69 @@
+---
+id: F-013
+name: User Anonymization (Opt-out logging)
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+When a specific user opts out of tracking (e.g. via an in-app privacy setting, or in response to a "do not track" regulatory requirement), the app needs a way to tell AppsFlyer to stop logging identifiable data for that user without tearing down the whole SDK. `anonymizeUser` flips this per-user opt-out flag on the native SDK. Without it, the only way to honor such a request would be the much blunter `stop()` API, which disables the SDK entirely rather than scoping the opt-out to one user.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app whenever the current user's tracking-opt-out preference changes (e.g. a settings toggle, or an automated privacy-compliance check at login).
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.anonymizeUser(shouldAnonymize) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("anonymizeUser", {'shouldAnonymize': shouldAnonymize})
+ β Android: AppsflyerSdkPlugin.onMethodCall("anonymizeUser") β anonymizeUser(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().anonymizeUser(shouldAnonymize)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("anonymizeUser") β anonymizeUser:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [AppsFlyerLib shared].anonymizeUser = shouldAnonymize
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `anonymizeUser(bool)` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `anonymizeUser` native handler |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `anonymizeUser:result:` native handler (direct property assignment) |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `shouldAnonymize` (bool) β `true` enables anonymized logging for the current user, `false` restores normal logging. |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+No dedicated test found. `test/appsflyer_sdk_test.dart` does not mock or call `anonymizeUser` anywhere, despite it being fully wired on both platforms.
+
+---
+
+## Known Limitations
+- No test coverage at all, unlike most other setters in this file β a regression in argument key naming (`shouldAnonymize`) on either platform would go undetected by CI.
+- The flag is process/instance-scoped (it toggles a property on the shared `AppsFlyerLib`/native singleton), not tied to a specific customer user ID β if the app switches logged-in users without also resetting this flag, the anonymization state can leak across user sessions.
+- No way to read back the current anonymization state from Dart (no `getAnonymizeUser()` counterpart) β the app must track the last value it set itself.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F013["F-013 Β· User Anonymization (Opt-out logging)"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-014-manual-deep-link-retrigger.md b/internal-docs/features/F-014-manual-deep-link-retrigger.md
new file mode 100644
index 00000000..7d57405b
--- /dev/null
+++ b/internal-docs/features/F-014-manual-deep-link-retrigger.md
@@ -0,0 +1,71 @@
+---
+id: F-014
+name: Manual Deep-Link Re-trigger (performOnDeepLinking)
+type: deepLinking
+platform: android
+status: active
+last_verified: 2026-07-15
+depends_on: ["F-037"]
+---
+
+## Business Purpose
+Apps that delay `startSDK()` (manual-start mode) can miss deep-link resolution for the launch intent, because AppsFlyer normally inspects the intent during its own lifecycle hooks around SDK start. `performOnDeepLinking()` lets the host app force the native SDK to re-process the activity's current intent on demand β typically right before a delayed `startSDK()` call β so a OneLink click that launched the app is still resolved even though initialization was deferred. Without this API, manual-start integrators on Android would silently lose deep-link data for the launch that started the app.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called explicitly by the host app, typically in a manual-start (`manualStart: true`) flow, immediately before invoking `startSDK()`/`startSDKwithHandler()` β e.g. after the app has finished its own startup gating (consent, config fetch, etc.) but still needs the original launch intent resolved for deep linking.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.performOnDeepLinking() [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("performOnDeepLinking")
+ β Android: AppsflyerSdkPlugin.onMethodCall("performOnDeepLinking") β performOnDeepLinking(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β intent = activity.getIntent()
+ β AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication)
+ β afDeepLinkListener.onDeepLinking(DeepLinkResult) [if UDL subscribed]
+ β runOnUIThread(..., AF_UDL_CALLBACK, AF_SUCCESS) β callbackChannel "callListener" β Dart onDeepLinking callback (see F-037)
+ β iOS: no "performOnDeepLinking" case in AppsflyerSdkPlugin.m's handleMethodCall: β FlutterMethodNotImplemented
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `performOnDeepLinking()` β platform-agnostic Dart API, no `Platform.isAndroid` guard |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `performOnDeepLinking(call, result)` β reads `activity.getIntent()` and forwards it to `AppsFlyerLib.getInstance().performOnDeepLinking(intent, mApplication)` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | No corresponding case in `handleMethodCall:` β the method name is entirely absent |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | None (no arguments passed from Dart) |
+| **Output** | Android: `void`; internally errors `"NO_INTENT"` if `activity.getIntent()` is null, or `"NO_ACTIVITY"` if the activity is null (Dart call is fire-and-forget and does not await/inspect these). No direct return value β the actual payload, if any, arrives asynchronously via the `onDeepLinking` callback (F-037). iOS: `MissingPluginException` / `FlutterMethodNotImplemented` since the method is unhandled. |
+
+---
+
+## Tests
+No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `performOnDeepLinking`.
+
+---
+
+## Known Limitations
+- **iOS has no implementation at all** β unlike most other Dart APIs in this plugin, `performOnDeepLinking` is not merely a no-op stub on iOS (compare `setIsUpdate` in F-016); the method name doesn't appear in `AppsflyerSdkPlugin.m`'s `handleMethodCall:` chain, so the platform channel call falls through to `FlutterMethodNotImplemented`. Since the Dart method doesn't await or handle the channel result, this failure is silent to the caller.
+- Documented as "Android Only!" in `doc/API.md`, confirming this is a deliberate platform restriction rather than an oversight β but the Dart API surface gives no compile-time signal of this, so cross-platform code calling it unconditionally will throw on iOS at the channel layer.
+- Depends on `activity` and `activity.getIntent()` being non-null at call time; if the Flutter engine is detached from its activity (e.g. during a configuration change), the call errors out natively but this is invisible to the fire-and-forget Dart caller.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F014["F-014 Β· Manual Deep-Link Re-trigger"]:::deepLinking -->|"result surfaces through"| F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking
+ classDef deepLinking fill:#E64980,color:#fff
+```
diff --git a/internal-docs/features/F-015-customer-user-id.md b/internal-docs/features/F-015-customer-user-id.md
new file mode 100644
index 00000000..83a1fe0e
--- /dev/null
+++ b/internal-docs/features/F-015-customer-user-id.md
@@ -0,0 +1,69 @@
+---
+id: F-015
+name: Customer User ID (CUID)
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+AppsFlyer generates its own device-scoped unique ID (`getAppsFlyerUID`), but businesses need to join AppsFlyer's attribution/reporting data (CSV exports, Postback APIs) against their own internal user records (account ID, CRM ID, etc.). `setCustomerUserId` lets the app register its own developer-defined ID alongside AppsFlyer's, so every report and postback can be cross-referenced against the app's own user database β without it, correlating AppsFlyer attribution data with internal user analytics would require a fragile, manual matching process.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app whenever it knows the user's internal identifier β typically right after login/signup, or as soon as the app's own user-identity system resolves an ID.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setCustomerUserId(id) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setCustomerUserId", {'id': id})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setCustomerUserId") β setCustomerUserId(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setCustomerUserId(userId)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setCustomerUserId") β setCustomerUserId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] setCustomerUserID:userId]
+```
+Note: the related (but distinct) Dart API `setCustomerIdAndLogSession(id)` invokes the channel method `"setCustomerIdAndLogSession"`, which Android handles with its own `setCustomerIdAndLogSession(call, result)` (calling `AppsFlyerLib.getInstance().setCustomerIdAndLogSession(userId, mContext)`), while iOS routes `"setCustomerIdAndLogSession"` to the *same* `setCustomerUserId:result:` handler as plain `setCustomerUserId` β iOS has no distinct "and log session" native behavior.
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setCustomerUserId(String)` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setCustomerUserId` native handler |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setCustomerUserId:result:` native handler |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `id` (String) β the developer-defined customer user ID. |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setCustomerUserId call` (line 266) asserts the mocked channel receives `setCustomerUserId`. No assertion on the argument value reaching native code beyond the channel dispatch mock.
+
+---
+
+## Known Limitations
+- No validation of the `id` string (empty string, whitespace, excessive length) before it is forwarded to native code β a blank ID is passed through unchanged on both platforms.
+- iOS silently reuses the plain `setCustomerUserId:` implementation for the separate `setCustomerIdAndLogSession` Dart API, while Android gives it genuinely distinct native behavior (`setCustomerIdAndLogSession(userId, mContext)`, tied to `waitForCustomerUserId`'s delayed-session-log flow) β cross-platform behavior for that related API is not equivalent, which is easy to miss since both share the same underlying `setCustomerUserId` naming.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F015["F-015 Β· Customer User ID (CUID)"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-016-update-vs-fresh-install-flag.md b/internal-docs/features/F-016-update-vs-fresh-install-flag.md
new file mode 100644
index 00000000..f8bdd8fc
--- /dev/null
+++ b/internal-docs/features/F-016-update-vs-fresh-install-flag.md
@@ -0,0 +1,68 @@
+---
+id: F-016
+name: Update vs. Fresh-Install Flag
+type: sdkCore
+platform: android
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Attribution logic needs to distinguish "this session came from a brand-new install" versus "this session came from an app that was just updated" β misclassifying updates as new installs would corrupt install-attribution counts and inflate campaign performance numbers. `setIsUpdate` lets the app tell the native SDK explicitly that the current launch follows an update (e.g. detected by comparing a stored app-version marker against the running version), which the SDK factors into its session/attribution logic on Android.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app at startup, after the app has itself determined (typically by comparing a persisted last-known app version against the current one) that this launch follows an update rather than a fresh install.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setIsUpdate(isUpdate) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setIsUpdate", {'isUpdate': isUpdate})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setIsUpdate") β setIsUpdate(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setIsUpdate(isUpdate)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setIsUpdate") β (no-op) [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setIsUpdate(bool)` β platform-agnostic Dart API (no `Platform.isAndroid` guard) |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setIsUpdate` native handler β forwards to `AppsFlyerLib.getInstance().setIsUpdate(isUpdate)` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `handleMethodCall:` contains an empty `else if([@"setIsUpdate" isEqualToString:call.method]){ }` branch β matched but intentionally does nothing |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `isUpdate` (bool) |
+| **Output** | Android: `void`, fire-and-forget, and `result.success(null)` is called so the Dart-side `Future` (if awaited) would resolve normally. iOS: the method-call branch matches but never calls `result(...)` at all. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setIsUpdate call` (line 136) asserts the mocked channel receives `setIsUpdate` with `isUpdate: true`, exercising only the Dart-to-channel dispatch (the mock test harness cannot and does not distinguish Android's real handling from iOS's no-op).
+
+---
+
+## Known Limitations
+- **iOS is a documented no-op**: in `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m`'s `handleMethodCall:`, the `"setIsUpdate"` branch is matched (`if([@"setIsUpdate" isEqualToString:call.method]){ }`) but its body is empty β no native AppsFlyer API is called, and critically, `result(...)` is never invoked either. Since this branch matches inside an `if/else if` chain, control does not fall through to the trailing `result(FlutterMethodNotImplemented)` β the platform channel's pending reply for `setIsUpdate` on iOS is simply never resolved. Dart's `setIsUpdate()` is `void` and does not await the result, so this is silent to the caller today, but the update-vs-install distinction this API is meant to convey has **no effect whatsoever on iOS** β only Android attribution logic actually receives it.
+- The Dart API has no platform guard and gives no compile-time or runtime signal that calling `setIsUpdate` on iOS is a no-op; an integrator relying on it cross-platform would reasonably but incorrectly assume parity with Android.
+- No enforced ordering relative to `initSdk()` β the native SDK's own documentation-level expectation (call before init so the flag is available for the very first session) is not validated by either native handler.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F016["F-016 Β· Update vs. Fresh-Install Flag"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-017-sdk-kill-switch.md b/internal-docs/features/F-017-sdk-kill-switch.md
new file mode 100644
index 00000000..064a0829
--- /dev/null
+++ b/internal-docs/features/F-017-sdk-kill-switch.md
@@ -0,0 +1,70 @@
+---
+id: F-017
+name: SDK Kill Switch (stop)
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Some legal, privacy, or contractual situations (e.g. a user invokes a "right to be forgotten," a regulator order, or a licensing dispute) require the app to fully halt all AppsFlyer network activity immediately, not just for one user but for the whole SDK instance. `stop(true)` is the bluntest tool in the plugin: it tells the native SDK to stop communicating with AppsFlyer's servers entirely. Without it, the only way to achieve the same effect would be to prevent the SDK from ever calling `initSdk()`/`startSDK()`, which is not possible once the app is already running with the SDK live. This is documented as an "extreme case" API for legal/privacy compliance, distinct from the narrower per-user `anonymizeUser` (F-013).
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app at any point during the app's lifetime β typically in response to a privacy/legal requirement (e.g. a "kill switch" remote config flag, a consent withdrawal flow, or during automated compliance testing) β to start or stop all SDK network communication.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.stop(isStopped) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("stop", {'isStopped': isStopped})
+ β Android: AppsflyerSdkPlugin.onMethodCall("stop") β stop(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().stop(isStopped, mContext)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("stop") β stop:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [AppsFlyerLib shared].isStopped = stop
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `stop(bool)` β Dart API surface |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `stop(call, result)` native handler, line 1033 |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `stop:result:` native handler (direct property assignment), line 734 |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `isStopped` (bool) β `true` halts all SDK network communication/activity; `false` re-enables it. |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check stop call` (line 143) asserts the mocked channel receives method `'stop'` with `capturedArguments['isStopped'] == true`. Native Android/iOS behavior is not exercised by any Dart test.
+
+---
+
+## Known Limitations
+- No way to read back the current stopped state from Dart β the host app must track the last value it set itself.
+- Calling `stop(true)` does not clear or reset any previously buffered/queued native SDK state; resuming with `stop(false)` re-enables communication but the plugin doc explicitly frames this as an "extreme" API not meant for routine toggling.
+- No test coverage of the native Android/iOS code paths, only the Dart-to-channel argument shape.
+- Distinct from `anonymizeUser` (F-013): `stop` disables the entire SDK instance for all users/sessions, while `anonymizeUser` scopes an opt-out to the current user only. Using `stop` where `anonymizeUser` was intended would be a significant over-reach in production.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F017["F-017 Β· SDK Kill Switch (stop)"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-018-uninstall-measurement.md b/internal-docs/features/F-018-uninstall-measurement.md
new file mode 100644
index 00000000..f7d12595
--- /dev/null
+++ b/internal-docs/features/F-018-uninstall-measurement.md
@@ -0,0 +1,74 @@
+---
+id: F-018
+name: Uninstall Measurement
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Attribution isn't just about installs β media sources and marketers also need to measure uninstalls to calculate true retention/ROI. AppsFlyer measures uninstalls by receiving silent push notifications and needs the device's push token registered against the install. `updateServerUninstallToken` is how the host app hands that token (FCM token on Android, APNs device token on iOS) to the native SDK. Without it, uninstall events never reach AppsFlyer's backend and uninstall-based campaign reporting/ROI calculations would be silently incomplete.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app whenever it obtains/refreshes its push token β typically inside a Firebase Messaging (`FirebaseMessaging.instance.getToken()` on Android / `getAPNSToken()` on iOS) callback, or from native `didRegisterForRemoteNotificationsWithDeviceToken:` on iOS.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.updateServerUninstallToken(token) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("updateServerUninstallToken", {'token': token})
+ β Android: AppsflyerSdkPlugin.onMethodCall("updateServerUninstallToken") β updateServerUninstallToken(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().updateServerUninstallToken(mContext, token)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("updateServerUninstallToken") β updateServerUninstallToken:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β hex-string token manually decoded into NSData
+ β [AppsFlyerLib shared] registerUninstall:deviceTokenData]
+
+AppsflyerSdk.enableUninstallTracking(senderId) [DEPRECATED β no-op] [lib/src/appsflyer_sdk.dart]
+ β prints a deprecation message only; does not invoke the method channel at all
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `updateServerUninstallToken(String)` (active), `enableUninstallTracking(String)` (`@Deprecated`, no-op) |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `updateServerUninstallToken(call, result)`, line 1027 |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `updateServerUninstallToken:result:`, line 740 β converts hex-string token to `NSData` before calling `registerUninstall:` |
+| `doc/AdvancedAPI.md` | "Measure App Uninstalls" section documents both the iOS-native (`registerUninstall:` in `AppDelegate.m`) and plugin-side paths, and the Firebase Messaging integration pattern |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `token` (String) β Android: FCM registration token, passed through as-is. iOS: APNs device token as a **hexadecimal string** (e.g. from `FirebaseMessaging.instance.getAPNSToken()`); the plugin strips spaces and manually converts each hex byte pair into raw `NSData` before calling `registerUninstall:`. |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check updateServerUninstallToken call` (line 150) asserts the mocked channel receives `'updateServerUninstallToken'` with `capturedArguments['token'] == 'token123'`. No test exercises `enableUninstallTracking` (there is nothing to assert β it never touches the channel), and no test covers the iOS hex-to-`NSData` conversion logic.
+
+---
+
+## Known Limitations
+- `enableUninstallTracking(senderId)` is `@Deprecated` and, unlike most other deprecated methods in this file, has been fully gutted β it only prints a message and does nothing else, even though the `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` method-dispatch table still has a (no-op) `enableUninstallTracking` branch left over from the old implementation.
+- On iOS, `updateServerUninstallToken`'s hex-string parsing has no length/format validation β a malformed or odd-length hex string will silently produce truncated/incorrect `NSData` rather than raising an error back to Dart.
+- The app is responsible for obtaining and refreshing the push token itself (e.g. via `firebase_messaging`); this API only forwards whatever string it is given, so a stale or missing token upstream silently degrades uninstall measurement with no error surfaced to the caller.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F018["F-018 Β· Uninstall Measurement"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-019-user-email-collection.md b/internal-docs/features/F-019-user-email-collection.md
new file mode 100644
index 00000000..adb200f3
--- /dev/null
+++ b/internal-docs/features/F-019-user-email-collection.md
@@ -0,0 +1,73 @@
+---
+id: F-019
+name: User Email Collection (with encryption)
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Some attribution and cross-device matching scenarios benefit from AppsFlyer knowing the user's email address(es) (e.g. matching web and app sessions for the same customer). Sending raw emails over the network is a privacy concern, so `setUserEmails` supports an optional SHA-256 hash instead of plaintext. Without this API, integrators wanting to correlate identities by email would have no supported channel to hand that data to the native SDK at all.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app once the user's email(s) become known β typically right after login/signup, or whenever the app wants to (re)associate the current session with one or more email addresses.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.setUserEmails(emails, cryptType) [lib/src/appsflyer_sdk.dart]
+ β cryptTypeInt = EmailCryptType.values.indexOf(cryptType) (defaults to 0 / EmailCryptTypeNone if omitted) [lib/src/appsflyer_constants.dart]
+ β _methodChannel.invokeMethod("setUserEmails", {'emails': emails, 'cryptType': cryptTypeInt})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setUserEmails") β setUserEmails(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β maps cryptTypeInt (0/1) to AppsFlyerProperties.EmailsCryptType.NONE / SHA256 (throws InvalidParameterException on any other value)
+ β AppsFlyerLib.getInstance().setUserEmails(cryptType, emails.toArray(new String[0]))
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setUserEmails") β setUserEmails:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β maps cryptTypeInt to native EmailCryptType (EmailCryptTypeNone / EmailCryptTypeSHA256)
+ β [AppsFlyerLib shared] setUserEmails:cryptType:]
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `setUserEmails(List, [EmailCryptType?])` β converts the enum to its integer index before sending |
+| `lib/src/appsflyer_constants.dart` | `enum EmailCryptType { EmailCryptTypeNone, EmailCryptTypeSHA256 }` β index 0/1 is the wire format sent to native |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `setUserEmails(call, result)`, line 983 β maps int to `AppsFlyerProperties.EmailsCryptType`, throws on unrecognized value |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `setUserEmails:result:`, line 761 β maps int to native `EmailCryptType` |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `emails` (`List`, required) β one or more user email addresses. `cryptType` (`EmailCryptType?`, optional) β `EmailCryptTypeNone` (default, index 0) sends plaintext; `EmailCryptTypeSHA256` (index 1) hashes before sending. |
+| **Output** | `void` β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check setUserEmails call` (line 244) calls `setUserEmails(["user@example.com"], EmailCryptType.EmailCryptTypeSHA256)` and asserts `capturedArguments['emails']` contains the email and `capturedArguments['cryptType']` equals the enum's index (1). The default (omitted `cryptType`, defaulting to index 0) path is not separately tested.
+
+---
+
+## Known Limitations
+- The enum-to-int mapping (`EmailCryptType.values.indexOf(cryptType)`) is a fragile contract: if the enum's declared order in `lib/src/appsflyer_constants.dart` is ever changed or a new value is inserted in the middle, the integer sent over the channel silently shifts meaning on both native platforms without any compile-time check tying the three enumerations together.
+- Android throws a Java `InvalidParameterException` for any `cryptTypeInt` outside `{0, 1}` β since the only public Dart entry point is the typed enum, this should be unreachable in practice, but a raw/dynamic method channel call bypassing the Dart API could trigger it.
+- No corresponding getter exists to read back which emails/crypt type were last set.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F019["F-019 Β· User Email Collection (with encryption)"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-020-appsflyer-uid-retrieval.md b/internal-docs/features/F-020-appsflyer-uid-retrieval.md
new file mode 100644
index 00000000..2fec51c9
--- /dev/null
+++ b/internal-docs/features/F-020-appsflyer-uid-retrieval.md
@@ -0,0 +1,68 @@
+---
+id: F-020
+name: AppsFlyer UID Retrieval
+type: sdkCore
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: []
+---
+
+## Business Purpose
+Every install gets a unique AppsFlyer-generated device/install ID, which is the primary key AppsFlyer uses internally to tie together attribution, in-app events, and reporting for that install. Host apps often need this same ID for their own backend correlation (e.g. sending it alongside server-side purchase records, or cross-referencing support tickets with AppsFlyer's dashboard/raw-data reports). `getAppsFlyerUID()` is the only supported way to read that ID from Dart.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called on demand by the host app β typically after SDK init, to attach the AppsFlyer ID to internal analytics, support diagnostics, or server-side event payloads.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.getAppsFlyerUID() [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("getAppsFlyerUID")
+ β Android: AppsflyerSdkPlugin.onMethodCall("getAppsFlyerUID") β getAppsFlyerUID(result) [android/.../AppsflyerSdkPlugin.java]
+ β result.success(AppsFlyerLib.getInstance().getAppsFlyerUID(mContext))
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("getAppsFlyerUID") β getAppsFlyerUID:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β result([[AppsFlyerLib shared] getAppsFlyerUID])
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `getAppsFlyerUID()` β `Future` async round-trip |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `getAppsFlyerUID(result)`, line 797 |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `getAppsFlyerUID:result:`, line 602 |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | None |
+| **Output** | `Future` β the AppsFlyer-generated unique ID for this install; may resolve to `null`/empty if the SDK has not finished initializing/generating the ID yet. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check getAppsFlyerUID call` (line 198) asserts the mocked channel receives `'getAppsFlyerUID'`. The test does not stub a return value, so the resolved-ID contract (nullable String) is not exercised.
+
+---
+
+## Known Limitations
+- No documented guarantee of the ID's availability timing relative to `initSdk()`/`startSDK()` β calling it too early (before the native SDK has generated/persisted the ID) can return an empty string or `null` depending on platform/SDK version, and the Dart API gives no way to await "ID ready."
+- No test coverage of the actual resolved value or of null/empty-string edge cases on either platform.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F020["F-020 Β· AppsFlyer UID Retrieval"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-021-delayed-session-start-pending-cuid.md b/internal-docs/features/F-021-delayed-session-start-pending-cuid.md
new file mode 100644
index 00000000..44f71c32
--- /dev/null
+++ b/internal-docs/features/F-021-delayed-session-start-pending-cuid.md
@@ -0,0 +1,77 @@
+---
+id: F-021
+name: Delayed Session Start Pending CUID
+type: sdkCore
+platform: android
+status: active
+last_verified: 2026-07-15
+depends_on: ["F-015"]
+---
+
+## Business Purpose
+Some apps only know the user's own customer ID (CUID) after login, but want every session β including the very first one β attributed with that ID rather than logging an "anonymous" session first. `waitForCustomerUserId(true)` tells the SDK to hold off logging the launch/session event until `setCustomerIdAndLogSession()` explicitly supplies the CUID and unblocks it. Without this pair of APIs, an app that authenticates after launch would either lose the CUID association on the first session or have to accept an anonymous first session in its AppsFlyer reporting.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+`waitForCustomerUserId(true)` is called during startup configuration (typically before or instead of relying on auto-start) to arm the delay. `setCustomerIdAndLogSession(id)` is called later, once the app has resolved the user's customer ID (e.g. after login), to supply the ID and release the held session.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.waitForCustomerUserId(wait) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("waitForCustomerUserId", {'wait': wait})
+ β Android: AppsflyerSdkPlugin.onMethodCall("waitForCustomerUserId") β waitForCustomerUserId(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().waitForCustomerUserId(wait)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("waitForCustomerUserId") β waitForCustomerId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β NO-OP β the method body only calls result(nil); no native AppsFlyerLib API is invoked
+
+AppsflyerSdk.setCustomerIdAndLogSession(id) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("setCustomerIdAndLogSession", {'id': id})
+ β Android: AppsflyerSdkPlugin.onMethodCall("setCustomerIdAndLogSession") β setCustomerIdAndLogSession(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().setCustomerIdAndLogSession(id, mContext)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("setCustomerIdAndLogSession") β setCustomerUserId:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β routed to the same handler as plain setCustomerUserId β [AppsFlyerLib shared] setCustomerUserID:id]; no "log session" semantics
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `waitForCustomerIdAndLogSession` split into `waitForCustomerUserId(bool)` and `setCustomerIdAndLogSession(String)` β no `Platform.isAndroid` guard on either |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `waitForCustomerUserId(call, result)` (line 971), `setCustomerIdAndLogSession(call, result)` (line 1009) β both proxy real native APIs |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `waitForCustomerId:result:` (line 757, no-op stub), `setCustomerIdAndLogSession` dispatch aliased to `setCustomerUserId:result:` (line 107/722) |
+| `doc/API.md` | Explicitly documents both APIs as **"Android only!"** (lines 440, 449) |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `waitForCustomerUserId`: `wait` (bool) β `true` delays session logging until a CUID is set. `setCustomerIdAndLogSession`: `id` (String) β the customer user ID to attach and the trigger to release the held session. |
+| **Output** | `void` for both β fire-and-forget; no confirmation returned to Dart. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β `check waitForCustomerUserId call` (line 260) asserts the mocked channel receives `'waitForCustomerUserId'`. No test exists for `setCustomerIdAndLogSession` β it is absent from the mock handler's recognized-method switch (line 24-66) entirely, so calling it in a test would not even register as a captured method.
+
+---
+
+## Known Limitations
+- **Effectively Android-only, despite no platform guard in Dart.** On iOS, `waitForCustomerId:` is a hollow stub (`result(nil)` only) β calling `waitForCustomerUserId(true)` on iOS has zero effect on session logging. `setCustomerIdAndLogSession` on iOS is silently routed to the same code as plain `setCustomerUserId` (just sets the customer ID property) with no "wait/release" behavior at all. This means an app that relies on this feature to guarantee CUID-attributed first sessions gets that guarantee only on Android; on iOS the first session logs immediately, unattributed, regardless of `waitForCustomerUserId(true)`.
+- The official docs (`doc/API.md`) do flag both APIs "Android only," but the Dart API surface itself has no runtime warning, assertion, or `Platform.isAndroid` check β an integrator who skips the docs and only reads code/dartdoc could easily assume cross-platform parity.
+- No test coverage at all for `setCustomerIdAndLogSession`, and no test verifies the delay/release semantics (mocks only assert the method name was invoked, not any ordering or blocking behavior).
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F021["F-021 Β· Delayed Session Start Pending CUID"]:::sdkCore -->|"iOS: routed to same native handler as"| F015["F-015 Β· Customer User ID (CUID)"]:::sdkCore
+ classDef sdkCore fill:#4C6EF5,color:#fff
+```
diff --git a/internal-docs/features/F-022-push-notification-deep-link-path-config.md b/internal-docs/features/F-022-push-notification-deep-link-path-config.md
new file mode 100644
index 00000000..1cd63e82
--- /dev/null
+++ b/internal-docs/features/F-022-push-notification-deep-link-path-config.md
@@ -0,0 +1,71 @@
+---
+id: F-022
+name: Push Notification Deep-Link Path Config
+type: deepLinking
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: ["F-037"]
+---
+
+## Business Purpose
+Push-notification re-engagement campaigns often embed a OneLink URL somewhere inside a custom, nested JSON payload rather than in a fixed top-level field β the exact location varies per app. `addPushNotificationDeepLinkPath` tells the native AppsFlyer SDK the JSON key-path where that OneLink URL lives, so the SDK can extract and resolve it as a deep link when the push payload is later handed to it. Without configuring this path, the SDK has no way to find the OneLink URL inside an arbitrarily-shaped push payload, and push-driven deep links silently fail to route users to the right in-app destination.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called once by the host app during startup configuration, **before** `initSdk()`/`startSDK()` is invoked β per `doc/API.md`, calling it after SDK start is unsupported. This registers the path so it's in place before any push payload is later delivered (see F-031).
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.addPushNotificationDeepLinkPath(List deeplinkPath) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("addPushNotificationDeepLinkPath", deeplinkPath)
+ β Android: AppsflyerSdkPlugin.onMethodCall("addPushNotificationDeepLinkPath") β addPushNotificationDeepLinkPath(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath(String[] path)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall("addPushNotificationDeepLinkPath") β addPushNotificationDeepLinkPath:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [[AppsFlyerLib shared] addPushNotificationDeepLinkPath:deeplinkPath]
+```
+The configured path is later consulted when a push payload reaches the native SDK (Android: automatically, from the launch/new intent extras; iOS: when `sendPushNotificationData`/`handlePushNotification` is called β see F-031), and any OneLink URL found at that path is resolved and delivered through the UDL `onDeepLinking` callback (F-037).
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `addPushNotificationDeepLinkPath(List)` β passes the path array directly as method-channel arguments (no wrapping map) |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `addPushNotificationDeepLinkPath(call, result)` β casts arguments to `ArrayList`, converts to `String[]`, forwards to `AppsFlyerLib.getInstance().addPushNotificationDeepLinkPath` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `addPushNotificationDeepLinkPath:result:` β forwards the `NSArray` directly to `[AppsFlyerLib shared]` if non-nil |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `deeplinkPath` (`List`) β ordered JSON keys describing where in the push payload the OneLink URL is nested (e.g. `["deeply", "nested", "deep_link"]`) |
+| **Output** | `void` β fire-and-forget; both native handlers call `result.success(null)`/`result(nil)` unconditionally (Android does so even if `call.arguments` is null, since the `if` guard just skips the native call but still succeeds). |
+
+---
+
+## Tests
+No dedicated test found. `test/appsflyer_sdk_test.dart` does not exercise `addPushNotificationDeepLinkPath`.
+
+---
+
+## Known Limitations
+- Must be called before SDK init/start per documentation; neither native handler nor the Dart method enforces or warns about ordering β calling it late is a silent no-op for that launch.
+- On Android this path config is sufficient on its own (the SDK auto-extracts from intent extras); on iOS it configures the path but does nothing until the payload is separately forwarded to the SDK via F-031's `sendPushNotificationData`/`handlePushNotification` β an integrator who configures the path on iOS but skips that step will see push deep links silently fail to resolve.
+- No validation of the path array shape (e.g. empty list, non-string elements) on either platform before forwarding to native code.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F022["F-022 Β· Push Notification Deep-Link Path Config"]:::deepLinking -->|"resolved OneLink URL is delivered via"| F037["F-037 Β· Unified Deep Linking (UDL) Callback & Models"]:::deepLinking
+ F022 -->|"iOS requires payload forwarded via"| F031["F-031 Β· Push Notification Data Handling"]:::deepLinking
+ classDef deepLinking fill:#E64980,color:#fff
+```
diff --git a/internal-docs/features/F-023-in-app-purchase-validation-v1.md b/internal-docs/features/F-023-in-app-purchase-validation-v1.md
new file mode 100644
index 00000000..34a452e7
--- /dev/null
+++ b/internal-docs/features/F-023-in-app-purchase-validation-v1.md
@@ -0,0 +1,86 @@
+---
+id: F-023
+name: In-App Purchase Validation V1 (Android/iOS separate APIs)
+type: purchaseValidation
+platform: both
+status: deprecated
+last_verified: 2026-07-15
+depends_on: ["F-038", "F-025"]
+---
+
+## Business Purpose
+Before the cross-platform V2 API existed, apps needed a way to send a purchase receipt directly to AppsFlyer's validation servers so that in-app-purchase revenue could be confirmed against the store (Google Play / App Store) rather than trusted at face value from the client. This is what lets AppsFlyer distinguish real, store-verified revenue from spoofed or refunded purchases in attribution and revenue reporting. `validateAndLogInAppAndroidPurchase` submits the Google Play `purchaseData`/`signature`/`publicKey` triple; `validateAndLogInAppIosPurchase` submits the App Store `productIdentifier`/`transactionId`. Both are now `@Deprecated` in favor of `validateAndLogInAppPurchaseV2` (F-024), but any app still calling them relies on this exact code path β removing it would break revenue validation for apps that have not migrated, with no automatic fallback.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app immediately after it detects a completed purchase from the platform store (Google Play Billing on Android, StoreKit on iOS) and wants that purchase validated and logged to AppsFlyer.
+
+---
+
+## Call Chain
+```
+Android:
+AppsflyerSdk.validateAndLogInAppAndroidPurchase(publicKey, signature, purchaseData, price, currency, additionalParameters) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("validateAndLogInAppAndroidPurchase", {publicKey, signature, purchaseData, price, currency, additionalParameters})
+ β AppsflyerSdkPlugin.onMethodCall case "validateAndLogInAppAndroidPurchase" β validateAndLogInAppPurchase(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β registerValidatorListener() // registers AppsFlyerInAppPurchaseValidatorListener (feeds F-038)
+ β AppsFlyerLib.getInstance().validateAndLogInAppPurchase(mContext, publicKey, signature, purchaseData, price, currency, additionalParameters)
+ β result.success(null) // Future resolves immediately; real result arrives later via the "validatePurchase" callback (F-038)
+
+iOS:
+AppsflyerSdk.validateAndLogInAppIosPurchase(productIdentifier, price, currency, transactionId, additionalParameters) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("validateAndLogInAppIosPurchase", {productIdentifier, price, currency, transactionId, additionalParameters})
+ β AppsflyerSdkPlugin.handleMethodCall case "validateAndLogInAppIosPurchase" β validateAndLogInAppPurchase:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β [AppsFlyerLib shared] validateAndLogInAppPurchase:productIdentifier price:currency:transactionId:additionalParameters:success:failure:
+ β success block β onValidateSuccess: β [_streamHandler sendResponseToFlutter:@"validatePurchase" status:@"success" data:response] [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m]
+ β failure block β onValidateFail: β [_streamHandler sendResponseToFlutter:@"validatePurchase" status:@"failure" data:errorObject]
+ β result(nil) // Future resolves immediately, same fire-and-forget pattern as Android
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `validateAndLogInAppAndroidPurchase(...)` and `validateAndLogInAppIosPurchase(...)`, both annotated `@Deprecated` |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `validateAndLogInAppPurchase(MethodCall, Result)` native handler; calls `registerValidatorListener()` and `AppsFlyerLib.getInstance().validateAndLogInAppPurchase(...)` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `validateAndLogInAppPurchase:result:` native handler; calls `[AppsFlyerLib shared] validateAndLogInAppPurchase:...]` with success/failure blocks routed through `onValidateSuccess:`/`onValidateFail:` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsFlyerStreamHandler.m` | `sendResponseToFlutter:status:data:` β forwards the async iOS validation result to Dart over the callback `MethodChannel` (`callbacks`) using `invokeMethod("callListener", ...)`, despite the class name suggesting an `EventChannel` |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | Android: `publicKey` (String), `signature` (String), `purchaseData` (String), `price` (String), `currency` (String), `additionalParameters` (Map?). iOS: `productIdentifier` (String), `price` (String), `currency` (String), `transactionId` (String), `additionalParameters` (Map). |
+| **Output** | The Dart `Future` returned by both methods resolves to `null` immediately (fire-and-forget) β it does **not** carry the validation result. The actual validation outcome (success/failure + response payload) is delivered asynchronously, out-of-band, through the `onPurchaseValidation` callback listener (F-038), keyed by callback id `"validatePurchase"`. |
+
+---
+
+## Tests
+`test/appsflyer_sdk_test.dart` β covers `validateAndLogInAppAndroidPurchase` only (asserts the method name string `"validateAndLogInAppAndroidPurchase"` and that `publicKey`/`price`/`currency` are forwarded correctly in the arguments map). No test exists for `validateAndLogInAppIosPurchase`.
+
+---
+
+## Known Limitations
+- Both APIs are `@Deprecated` with a doc comment pointing to `validateAndLogInAppPurchaseV2` (F-024), and are marked for removal in a future version β new integrations should not use them.
+- The Dart `Future` resolves to `null` on both platforms as soon as the native call is dispatched, not when validation actually completes β callers cannot `await` a result from these methods; they must separately register `onPurchaseValidation` (F-038) to observe the outcome. This asynchronous split is easy to miss and is not documented in the dartdoc for either method.
+- iOS delivers its result via `AppsFlyerStreamHandler.sendResponseToFlutter`, which despite its name and the class being wired to a `FlutterEventChannel`, actually pushes data through the callback `MethodChannel` (`callListener`) instead of an `EventSink` β the Dart-side `EventChannel` (`af-events`) instantiated in `appsflyer_sdk.dart` is never `.listen()`-ed to anywhere in `lib/`.
+- No test coverage at all for the iOS path (`validateAndLogInAppIosPurchase`), only the Android path is asserted in `test/appsflyer_sdk_test.dart`.
+- On Android, the validated result is only forwarded to Dart if `onPurchaseValidation` was registered *before* the validation completes (gated by the `validatePurchaseCallback` boolean flag); on iOS, `sendResponseToFlutter` has no such gate and always attempts to forward, which is an asymmetry between the two native implementations of the same nominal feature.
+
+---
+
+## Dependencies
+```mermaid
+flowchart LR
+ F023["F-023 Β· In-App Purchase Validation V1"]:::purchaseValidation
+ F038["F-038 Β· Legacy Purchase-Validation Notification Callback"]:::purchaseValidation
+ F025["F-025 Β· iOS Receipt Validation Sandbox Toggle"]:::purchaseValidation
+ F023 -->|"delivers async result via"| F038
+ F023 -->|"iOS: validates against endpoint set by"| F025
+ classDef purchaseValidation fill:#F59F00,color:#fff
+```
diff --git a/internal-docs/features/F-024-in-app-purchase-validation-v2.md b/internal-docs/features/F-024-in-app-purchase-validation-v2.md
new file mode 100644
index 00000000..bfc6ed29
--- /dev/null
+++ b/internal-docs/features/F-024-in-app-purchase-validation-v2.md
@@ -0,0 +1,83 @@
+---
+id: F-024
+name: In-App Purchase Validation V2 (cross-platform)
+type: purchaseValidation
+platform: both
+status: active
+last_verified: 2026-07-15
+depends_on: ["F-025"]
+---
+
+## Business Purpose
+`validateAndLogInAppPurchaseV2` replaces the deprecated, platform-specific V1 APIs (F-023) with a single cross-platform entry point built around the `AFPurchaseDetails` model, so app developers write one call site instead of branching on `Platform.isAndroid`/`Platform.isIOS`. It lets AppsFlyer verify purchase/subscription revenue against the store (Google Play or App Store) and, unlike V1, returns the actual validation result (or a structured error) directly on the `Future`, so the app can react to a failed validation (e.g. refuse to unlock content) at the call site instead of wiring a separate global listener. Without this feature, apps would have to fall back to the deprecated, harder-to-use, fire-and-forget V1 APIs to get server-side purchase validation at all.
+
+> TODO: enrich from product specs β provide a Notion database URL and re-run Phase 4 to fill this automatically.
+
+---
+
+## Trigger
+Called by the host app after it detects a completed purchase or subscription renewal from the platform store, whenever it wants a synchronous (awaited) validation result back from AppsFlyer.
+
+---
+
+## Call Chain
+```
+AppsflyerSdk.validateAndLogInAppPurchaseV2(purchaseDetails, {additionalParameters}) [lib/src/appsflyer_sdk.dart]
+ β _methodChannel.invokeMethod("validateAndLogInAppPurchaseV2", {
+ 'purchaseDetails': purchaseDetails.toMap(), // {purchaseType, purchaseToken, productId} [lib/src/af_purchase_details.dart]
+ 'additionalParameters': additionalParameters,
+ })
+ β Android: AppsflyerSdkPlugin.onMethodCall case "validateAndLogInAppPurchaseV2" β validateAndLogInAppPurchaseV2(call, result) [android/.../AppsflyerSdkPlugin.java]
+ β mapPurchaseType(purchaseTypeString) // "subscription" β AFPurchaseType.SUBSCRIPTION, "one_time_purchase" β AFPurchaseType.ONE_TIME_PURCHASE
+ β new AFPurchaseDetails(purchaseType, purchaseToken, productId)
+ β AppsFlyerLib.getInstance().validateAndLogInAppPurchase(purchaseDetails, additionalParameters, AppsFlyerInAppPurchaseValidationCallback)
+ β onInAppPurchaseValidationFinished(...) β result.success(flutterResult)
+ β onInAppPurchaseValidationError(...) β result.error("VALIDATION_ERROR", errorMessage, flutterErrorResult)
+ β iOS: AppsflyerSdkPlugin.handleMethodCall case "validateAndLogInAppPurchaseV2" β validateAndLogInAppPurchaseV2:result: [ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m]
+ β maps purchaseType string to AFSDKPurchaseType, purchaseToken β transactionId
+ β new AFSDKPurchaseDetails(productId, transactionId, purchaseType)
+ β [AppsFlyerLib shared] validateAndLogInAppPurchase:purchaseAdditionalDetails:completion:
+ β completion(response, nil) β result(response)
+ β completion(nil, error) β result([FlutterError code:"VALIDATION_ERROR" ...])
+```
+
+---
+
+## Files
+| File | Role |
+|------|------|
+| `lib/src/appsflyer_sdk.dart` | `validateAndLogInAppPurchaseV2(AFPurchaseDetails, {additionalParameters})` |
+| `lib/src/af_purchase_details.dart` | `AFPurchaseDetails` model (`purchaseType`, `purchaseToken`, `productId`) and `AFPurchaseType` enum (`oneTimePurchase`, `subscription`); `toMap()` serializes `purchaseType` to `"one_time_purchase"` / `"subscription"` strings for the channel |
+| `android/src/main/java/com/appsflyer/appsflyersdk/AppsflyerSdkPlugin.java` | `validateAndLogInAppPurchaseV2(MethodCall, Result)` handler; `mapPurchaseType(String)` translates the Dart string enum to the native `AFPurchaseType` |
+| `ios/appsflyer_sdk/Sources/appsflyer_sdk/AppsflyerSdkPlugin.m` | `validateAndLogInAppPurchaseV2:result:` handler; inline string comparison maps to `AFSDKPurchaseType` (note: `purchaseToken` from Dart is passed as iOS `transactionId`) |
+
+---
+
+## Input / Output
+| | |
+|--|--|
+| **Input** | `purchaseDetails` (`AFPurchaseDetails` β map with `purchaseType` string, `purchaseToken`, `productId`), `additionalParameters` (`Map?`, optional). |
+| **Output** | `Future