Skip to content

CTO Playbook — Snag ​

Audit reconciliation, roadmap and build-out guide ​

Naming note. The product is called Snag — SKN was the working name, dropped on 2026-07-29. The GitHub organisation followed on 2026-07-30: it is now snag-tcg (snag alone was already taken by a user account). See the rename history for what did and did not move — the VPS directories deliberately keep the old name, because ten live bind mounts resolve through them.

What still carries the old name: the repository names, the in-app brand ("Passer à SKN Pro"), the store display name ("TCG Collector"), and above all the applicationId — the com.skntcg.collector suggestion is obsolete. That identifier becomes permanent at first publication, so it must be re-decided — presumably as com.snag… — before anything is uploaded to a store. Repository renames are reversible and can wait; that one cannot.

Date: 2026-07-27, updated 2026-07-29 Author: synthesis of the three sources below, with the contradictions arbitrated Audience: Kael (CTO / Tech Lead / Infra Lead) Target team: 2 developers, 2 POs (Nini — Head of Marketing, Spleen — PO), plus the CTO

The three reconciled sources ​

DocScopeStrengthLimit
synthese-infra-build-test-mobile.mdiOS/Android build & distribution, PO ticket formatThe only document covering the chain all the way to the testerAssumes a healthy build pipeline that does not exist yet
zai_audit_report_…mdTDD/BDD audit across 5 repos, plus deployment, security, PRsThe only one covering all 5 repos, and it caught defects the other missedUnreliable numbers (see §2.3); generated YAML to treat as drafts
claude_audit_report_…mdTDD/BDD audit on 2 repos, team guardrails, security/GDPR/pentestMeasured, verifiable facts; decisive arbitrationsAudited only 2 of 5 repos; ignores iOS and distribution

None of the three is sufficient alone. The first describes a distribution chain with no guardrails, the second covers everything but measures wrong, the third measures right but covers narrowly. This document is their arbitrated intersection.


Table of contents ​


0. How to use this document ​

This document serves four purposes. Do not read it linearly.

You want to…Read
Understand where we stand§1, §2.2, §2.3 — 15 minutes
Decide§3 (seven decisions), §4.2 (workflow variants), Appendix A (budget)
Present to the teamAppendix B, then §7 (the team contract) — that is what concerns them
Build the infrastructure§6, in order. It is a runbook, not an essay
Create skills§10

One reading rule. When this document says "measured", the value was verified by command on 2026-07-27/29. When it says "estimated" or "to verify", it is a hypothesis. The two source audits mix the two registers without distinguishing them; that is the first thing this reconciliation fixes.


1. One-page summary ​

What is going well. The mobile repo is healthy: 1,757 passing tests, strict TypeScript, naming discipline clearly above average, and a design system whose contrast is build-verified across 20 palettes × 2 schemes. The backend has a serious CI (typecheck, unit tests, docker build, API smoke, real-Postgres integration) and careful security design: constant-time token comparison, explicit public-surface allowlist, fail-closed defaults, fully parameterised SQL.

What is not going well. Four things, and they are structural.

  1. Postgres is backed up nowhere. No cron job, no backup directory — measured. The database holds the catalog and 143,452 embeddings; losing it costs hours to days of rebuilding, with no product in the meantime. This is the most urgent action in this document, ahead of the guardrails and ahead of CI (§8.7).

  2. No guardrail exists in any strict sense. The repos are private on a GitHub Free organisation: branch protection is not available. The "no commits on main" hook constrains only the Claude agent, not a human. The husky pre-commit hook has been a no-op forever. The mobile repo has no CI at all. What holds the project together today is that a single person touches it.

  3. The test harness protects the wrong code. 1,757 tests, and zero on the 22 screens or on the data path the application actually takes. The last four bugs all went through that blind spot, three of them through an integration seam that does not exist on mobile.

  4. The distribution chain does not exist. No PO can test anything today. No Apple account, no iOS build machine, no distribution channel.

Order matters as much as content. Putting guardrails on a harness that lies industrialises the production of meaningless green checks. Wiring distribution onto an untested build ships unverified code faster to non-technical people who cannot tell a bug from an unfinished feature.

Non-negotiable sequence: fix what lies → put up the guardrails → wire the distribution → open the team.

Cost of the whole thing: about €40/month plus $99/year, excluding optional hardware. See Appendix A and §4.2.


2. Reconciliation ​

2.1 The agreements — execute without debate ​

These points are asserted by at least two sources and verified. They require no arbitration: they are tasks.

#FindingSourcesAction
A1No mutation testing anywherezai + claudeStryker on a short list (§2.2-D)
A2No executable Gherkin specificationzai + claude1 executable .feature on the scan journey
A3The backend catalog .feature is not wired to any runnerzai + claudecucumber-js, or accept it as documentation
A4The scan pipeline has no end-to-end speczai + claudeIt is the product; it is the BDD priority
A5Index reload concurrent with an in-flight scan: real riskzai + claudeSee §2.2-C on the mechanism
A6Python nearly untested and outside CIzai + claudepytest in CI
A7Integration tests to automate, zero manual stepszai + claude§2.2-F for the boundary
A8iOS requires an Apple account + a macOS machineinfra (alone, but factual)Decisions §3-2 and §3-3
A9Firebase App Distribution compiles nothinginfraIt hosts signed binaries; it is not a CI
A10PO tickets need testable acceptance criteriainfra + claude (receivability)§7.4

2.2 The contradictions, arbitrated ​

Six real divergences. For each: what each source says, the evidence, the verdict.


A — Mobile design quality: 8/10 or structural defect? ​

zai: "Design Quality 8/10 — clean architecture with well-controlled layer separation, minimal accidental coupling." claude: the tested layer is not the used layer; layer violation in ScannerScreen; six dead screens in design-system/.

Evidence — measured imports:

src/data/repos.ts        → 6 interfaces, 6 implementations, all named Mock*
11 screens               → import @app/data/demo/catalog directly
design-system/src        → 68 imports of @/design, 1 of @/components, 0 of @/screens

Verdict: both are right, at different altitudes — and that matters.

zai judges intra-module quality: literal types, dependency injection, domain-aligned naming. At that level, 8/10 is deserved and I confirm it.

claude judges inter-module quality: which path the code actually takes. At that level, there is a fully tested repository abstraction the application never uses, and a demo catalog — which contains the portfolio computation, i.e. the money — that it uses everywhere.

What this changes for you: do not be reassured by the 8/10. A repo can be excellent module by module and fragile in its assembly. That is precisely the defect no aggregate score captures, and it is the one that produced the bugs.

Action: decide on a single data path (wire the repositories in, or delete them and test demo/catalog). High priority, before the devs arrive — otherwise two people will build on two different paths.


B — Test-first discipline: B+ or bimodal? ​

zai: mobile 8/10, backend 4/10 ("test-after patterns dominate"). claude: the mobile repo is bimodal — the scan pipeline is test-driven, the UI is not at all.

Evidence — test-files to source-files ratio per commit:

3e5990d fix(scanner): route mobile scans through SKN   tests=7  src=9
5d8951e fix(scanner): simplify scan mode selection     tests=4  src=3
---
ecacc77 feat(screens): redesign every screen            tests=1  src=27
a8cffa9 fix(ui): resolve the review findings            tests=2  src=24

Verdict: zai's single score hides the useful information. 8/10 averages a subsystem near 1:1 (the signature of a red-green cycle) with another at 1:27 (a bulk landing). The average does not tell you where to spend effort; the bimodality does.

Action: do not demand "more TDD" globally. Demand render tests on three screens — Scanner, CardDetail, Wallet — which is exactly where the ratio collapses.


C — The index-reload "race condition" ​

zai: "❌ TOCTOU RACE — double-checked locking without a lock" on refreshScanIndex, recommending "proper locking". claude: an async boundary where the invariant can break silently — a scan can match against a half-swapped index.

Verdict: right worry, wrong mechanism — and zai's proposed fix would be useless.

ts
if (scanIndexReloading) return;
scanIndexReloading = true;

Node is single-threaded with an event loop: no preemption can occur between these two synchronous statements. So this is not a TOCTOU, and a mutex would change nothing.

The real risk is elsewhere and it is real: a scan request that starts before the index swap and finishes after it can read an inconsistent state, or mix two index generations.

Correct action: guarantee an atomic reference swap (build the new index completely, then reassign in one statement) and make each request capture the reference exactly once at the start of processing. Plus a test that crosses a reload with an in-flight request — none exists.

What this example teaches: a model-produced audit can name a real symptom with the wrong vocabulary and propose an inoperative fix. It needs review by someone who knows the execution model. That is exactly your role.


D — Which modules to mutate first? ​

The two lists differ and both are defensible.

zai proposesclaude proposes
scan/backendScan.ts (validation)design/contrast.ts
data/repoFactory.tsscan/matching.ts (backend)
data/AsyncStorageScanHistoryRepo.tsscan/phash.ts (backend)
demo/catalog.ts::walletTotals
ui/format.ts::formatMoney

Verdict: merge them, and order by leverage, not perceived criticality.

The right criterion is not "is this module important" but "how many assertions collapse if this module is wrong".

RankModuleWhy this rank
1design/contrast.ts1,270 assertions rest on it. If it is wrong, they are wrong together and the build stays green. Best leverage in the repo.
2scan/matching.ts (backend)It is the product. It is where the bug that broke 100% of scans lived.
3scan/backendScan.ts (mobile)zai's addition, justified: ~40 lines of validation conditions on untrusted input.
4demo/catalog.ts::walletTotals + ui/format.tsMoney shown to the user.
5scan/phash.ts (backend)Thresholds and distances: canonical surviving-mutant territory. Pair with fast-check.

repoFactory.ts and AsyncStorageScanHistoryRepo.ts drop off — the first because decision §2.2-A may delete it, the second because its real problem is concurrency, which is tested directly, not by mutation.


E — iOS or Android first? ​

Three positions in the sources, plus a fourth in the code:

SourcePosition
synthese-infraiOS first, "more bankable"
claude_auditAndroid first, for lack of hardware
poc-tcg-collector/CLAUDE.md"iOS is not on the roadmap — don't add iOS-specific code paths"
Repo realityAndroid only: no exploited ios/ folder, Gradle build alone

Verdict: iOS-first is a business decision, it wins — and it makes the CLAUDE.md actively harmful.

That file is read by every agent before every task. As long as it says "iOS is not on the roadmap", it steers agents against your strategy. It already contains three other false claims (React Navigation, zustand, sqlite — measured absent). Fixing it is the very first roadmap action, and it costs an hour.

Nuance to keep: iOS priority does not mean dropping Android. Android remains the fast, free verification path — Gradle runs on your Linux VPS with no Apple account and no macOS. Keep both; prioritise iOS for PO distribution.


F — "Zero manual testing": how far? ​

zai: a whole chapter "AUTOMATED INTEGRATION TESTS — ZERO MANUAL", target 100%. claude: three things must not be automated.

Verdict: agreement on execution, disagreement on judgement — and the distinction is what saves the automation.

Automate 100% of execution: yes, without reservation. No test step may depend on someone remembering to run it.

But three decisions must stay human, or automation carves mistakes in stone:

  1. Accepting a golden diff. Regeneration is a command; acceptance is a review. A golden accepted blind turns a bug into a reference.
  2. Arbitrating surviving mutants. Some are semantic equivalents: killing them costs more than the guarantee gained.
  3. The scan similarity threshold. It is a product trade-off — precision against recall. It is measured automatically (the benchmark exists), it is decided humanly.

The phrase to repeat to the team: "zero manual execution, not zero human judgement".


2.3 Reliability of the numbers ​

An important point for a CTO about to rely on these documents: the zai audit's numbers are not reliable.

Metriczai claimsMeasured 2026-07-27Gap
Test cases (mobile)6071,757×2.9
Test suites12755wrong
Test files5548wrong
Source files101119wrong
bash
$ npx jest --ci --silent
Test Suites: 1 skipped, 54 passed, 54 of 55 total
Tests:       4 skipped, 1757 passed, 1761 total

What this implies, and what it does not.

It does not invalidate the zai audit: its qualitative findings are largely right, and it caught at least one real defect the other audit missed (§2.4). But its scores (7.5/10, 4/10, 1/10) rest on false measurements and must not serve as tracking indicators or be presented to the team as a state of play.

Governance rule to adopt: any metric that drives a decision must be produced by CI, not by a report. A number in a document is a photograph that ages; a number in a job is a measurement. It is also what makes §5 verifiable.

Likewise, the GitHub Actions workflow YAML blocks in the zai audit are generated drafts: useful starting points, never to copy without review — several contain patterns §6.6 explicitly advises against.

2.4 What each document missed ​

synthese-infra missed: the entire quality question. The pipeline it describes ships a binary to Nini and Spleen without a single test having to pass. Shipping unverified code faster to non-technical people turns your POs into regression detectors — the most expensive and demoralising job on the team.

zai missed: the enforcement layer of the guardrails. It proposes branch protection rules without seeing that the GitHub Free plan does not allow them on private repos — so nothing it describes can be enabled as-is. It also missed the no-op husky hook and the total absence of mobile CI.

claude missed:

  • Three repos out of five. tcg-app2 (Python FastAPI backend, ~2% coverage), tgc-app (dead copy), frontend (placeholder).
  • A real test defect zai correctly identified: src/api/tests/query.test.mjs asserts on the generated SQL string —
js
assert.equal(whereSql, 'WHERE set_id = $1 AND rarity = $2 AND name ILIKE $3');
assert.equal(whereSql, "WHERE data->'types' ? $1");

That is a white-box test: any refactor of the builder breaks the test without any behaviour change. The right test queries the API and checks the returned cards. Point to zai.

  • The whole iOS chain and distribution.

Concrete consequence, to decide (§3-4): tgc-app is not even a git repo (no history) and duplicates tcg-app2's files, whose last commit dates to 19 May 2026. Two dead folders in the workspace are two places where a new developer can work by mistake. (Update 2026-07-29: both were deleted from the VPS; the local workspace copies remain.)


3. The seven decisions that are yours ​

No audit can make them for you. Each unlocks part of §5.

Decision 1 — Move the GitHub organisation to the Team plan ​

Measured fact: snag-tcg is on the free plan, both repos are private. On that combination GitHub offers no rulesets, no branch protection, no required reviews, no required checks, no environment protection.

Without this decision, §5 through §7 of this document are inapplicable. Only conventions would remain — which is what exists already, and it only holds because one person writes code.

Cost: ~$4/user/month × 5 ≈ $20/month. Recommendation: yes, immediately. Best guarantee-per-euro in the project.

Decision 2 — Confirm iOS-first and open the Apple account ​

Recommendation: yes, if "more bankable" is backed by the market. $99/year, subscribable from Windows. Immediate consequence: fix CLAUDE.md (§2.2-E) the same day, or the agents will work against the decision.

Decision 3 — How to compile iOS? ​

synthese-infra lays out three paths. Here they are compared, the third included — it had disappeared from this playbook's first version, which hid the arbitration instead of making it.

A — Mac miniB — macOS VPS (MacStadium, EC2 Mac)C — Codemagic
Cost€600–800 oncemonthly rental, > A in steady usefree then usage-based
Setupdayshourshours
MaintenanceXcode, certificates, availability — yourshardware managed, OS yoursnone
IntegrationSSH from the orchestratorSSH / APIAPI/webhook, native RN + Firebase
Apple certificatesmanualmanualautomated via App Store Connect key

Recommendation: C, Codemagic, to start. You are alone on infra; a build machine, owned or rented, is an on-call duty. B is the worst of the three here: it costs more than an amortised Mac mini while leaving certificate management to you — it combines C's price with A's work. It would only make sense at a build volume that made Codemagic more expensive — a problem you do not have.

Re-evaluate when the Codemagic bill exceeds a Mac mini's amortisation. It is an easy switch, not a one-way door.

Prerequisite all three documents forgot: there is no iOS project today — no ios/ folder, no Podfile, no .xcodeproj. Add to that a custom native Kotlin module, quaddetector (card corner detection), with no iOS equivalent. The Apple account and Codemagic are useless until that work is done: iOS-first is development first, infrastructure second. Only compilation requires macOS; generating the project and porting the native module can be written from Windows.

Decision 4 — What to do with tcg-app2, tgc-app and frontend? ​

Recommendation: archive tgc-app (dead copy, outside git) and frontend (placeholder); explicitly decide the fate of tcg-app2 — either it enters the quality perimeter (CI, pytest, guardrails) or it is archived. What is untenable is the status quo: a Python repo at ~2% coverage, without CI, that nobody knows is alive. (Update 2026-07-29: both VPS copies deleted; the GitHub remote Tormknd/tgc-app still holds the code.)

Decision 5 — PO ticket format ​

synthese-infra proposes two levels. Recommendation: its own recommendation, which is the right one — POs write in natural language, an agent converts to the structured format. Do not impose Given/When/Then on Nini and Spleen: the learning cost would fall on them and ticket quality would drop. See §7.4 and the spec-from-ticket skill (§10.4).

Decision 6 — POs' GitHub permissions ​

Recommendation: Write role, same as the devs. Once the rulesets are in place, it is no longer the permission that protects but the protected branch. Downgrading them creates friction without gaining security. What bounds the blast radius of their mistakes is CODEOWNERS + staging. Admin: you alone — an admin can edit a ruleset.

Decision 7 — Scan photo retention ​

Measured fact: every archived scan writes the user's raw photo to the VPS disk, and there is no purge, no TTL, no unlink anywhere in the code. Unlimited retention.

Recommendation: 30-day retention + automatic purge, and above all crop to the detected card before writing. The quad detector already knows the outline. You keep what serves debugging without keeping the background — which is precisely the personal part. This measure removes the problem instead of managing it. (Update 2026-07-29: the existing backlog of archived cases was purged manually; the automatic retention job remains to be built.)


4. The target system ​

4.1 The shared trunk — the four gates ​

One picture, merging the three documents. It is the trunk that every variant in §4.2 shares — they differ only in who orchestrates the agents and who compiles iOS.

         ┌──────────────────────────────────────────────────────┐
         │  TICKET  —  Slack / GitHub Issues / Discord / …       │
         │  written in natural language by Nini or Spleen        │
         └───────────────────────┬──────────────────────────────┘
                                 │  spec-from-ticket skill
                                 ▼
         ┌──────────────────────────────────────────────────────┐
         │  STRUCTURED GITHUB ISSUE (user scenario + Given/When/ │
         │  Then + out-of-scope + done criteria)                 │
         └───────────────────────┬──────────────────────────────┘
                                 │  "Create a branch" from the issue
                                 ▼
         ┌──────────────────────────────────────────────────────┐
         │  feature/xxx  ·  dev or agent                         │
         └───────────────────────┬──────────────────────────────┘
                                 │  PR → staging
     ┌───────────────────────────┴───────────────────────────┐
     │  GATE 1 — RECEIVABILITY  (deterministic, blocking)     │
     │  R1 branch name consistent with the diff               │
     │  R2 tied to an issue / spec / fix report                │
     │  R3 documentation updated, EN/FR parity                 │
     ├────────────────────────────────────────────────────────┤
     │  GATE 2 — CORRECTNESS  (deterministic, blocking)        │
     │  typecheck · lint · tests · contract · gitleaks · build │
     ├────────────────────────────────────────────────────────┤
     │  GATE 3 — JUDGEMENT  (agents)                           │
     │  R4 fit to the ticket     → visible, non-blocking       │
     │  Manu  code review        → visible, non-blocking       │
     │  Zaher security           → BLOCKING on critical/high   │
     ├────────────────────────────────────────────────────────┤
     │  GATE 4 — HUMAN                                         │
     │  1 approval (staging) · 2 + CTO (main)                  │
     └───────────────────────────┬────────────────────────────┘
                                 ▼
                            ╔═════════╗
                            ║ staging ║  ← integration
                            ╚════╤════╝
              ┌──────────────────┼──────────────────┐
              ▼                  ▼                  ▼
      [staging VPS]      [Codemagic → .ipa]   [VPS → .apk]
              │                  └────────┬─────────┘
              │                           ▼
              │              ┌────────────────────────┐
              │              │ Firebase App           │
              │              │ Distribution           │
              │              │ product-owners group   │
              │              └───────────┬────────────┘
              │                    ┌─────┴─────┐
              │                    ▼           ▼
              │              [Nini iPhone] [Spleen iPhone]
              │                    │           │
              │                    └─────┬─────┘
              │                          ▼
              │              feedback commented on the original issue
              ▼
        ╔══════╗   release PR · 2 approvals · CTO
        ║ main ║   production deploy = MANUAL APPROVAL
        ╚══════╝

Three principles carry the whole diagram:

  1. Nothing enters without an origin. A PR not tied to an issue or a spec is a decision nobody made. With one person it is invisible; with five it is what produces the debt.
  2. The deterministic blocks, judgement informs. Sole exception: security, whose cost asymmetry justifies blocking on a judgement.
  3. The merge belongs to the team, production belongs to you. It is the last barrier once everything else has gone green.

4.2 Five implementation variants, by budget ​

The §4.1 trunk never changes: issue → gates → staging → build → PO. What changes with the company's cash position are two independent choices:

  • Who orchestrates the agents. A cron on the VPS driving claude -p — covered by the subscription, zero marginal cost, but quota shared with your own sessions. Or the official GitHub Action claude-code-action — API key billed per token, native GitHub logs, ~30 s latency.
  • Who compiles iOS. Codemagic — nothing to maintain, billed per minute past the free tier. Or a Mac mini — one-time purchase, maintenance on you.

Crossed, these two choices give four variants, plus the status quo:

iOS: CodemagiciOS: Mac mini
Agents: VPS cron (subscription)V2 — recommended startV4
Agents: GitHub API (Action)V3V5 — cruising speed

V1, the status quo, is on no square: no orchestration, no iOS build.

And the reminder that frames everything: the target clientele is on iPhone. A variant without an iOS chain ships nothing for the POs to test — that criterion disqualifies V1, not its cost.

V1 — Status quo: devs push, nobody tests ​

mermaid
flowchart LR
  D[Devs + Claude Code] --> R[PR + agent review] --> M[main]
  M -.no iOS build.-> X((POs: nothing<br/>to test))

Devs code with Claude Code, with or without review agents; PRs go out; you merge. No distribution: neither Nini nor Spleen can install anything, and iOS does not exist.

Additional cost: €0/month. The real cost shows on no invoice: every product validation goes through you, and the iOS-first goal does not move. V1 is not an option — it is the starting point to leave.

mermaid
flowchart TB
  S[Slack or issue form] --> I[GitHub issue · from-po label]
  I --> C[VPS cron · claude -p<br/>subscription, zero marginal cost]
  C -->|spec, code, review| PR[Pull request]
  PR --> G{4 gates}
  G --> ST[staging · merge by agent or CTO]
  ST --> CM[Codemagic · signed iOS build]
  ST --> AN[VPS · Android build]
  CM --> F[Firebase App Distribution]
  AN --> F
  F -->|e-mail| PO[POs/BAs · iPhone]

The idea is born in Slack or directly in the issue form (§7.3–7.4). The VPS cron detects the label, claude -p converts the spec, codes, opens the PR; the gates run; the merge to staging may be done by the agent when everything is green — main stays human. Codemagic signs and builds iOS, the VPS builds Android, Firebase distributes, the e-mail goes out to the POs.

On the "Slack → issue, for free" question: yes — the official GitHub app for Slack can create an issue from a message at no cost. But the issue form remains the recommended entry: it is what makes the "done when…" field mandatory. A home-grown webhook would do the same while adding infrastructure to maintain and an injection surface (§5.9) — avoid it.

Cost: ~€8/month — the Apple Developer Program ($99/year) pro-rated, unavoidable for signing iOS. Codemagic stays free below its tier (~500 macOS minutes/month, on the order of 20–25 React Native builds — verify on your exact config), then ~$0.10/minute. The agents cost nothing extra: the subscription covers them — but the quota is shared with your own sessions, and that contention is what will one day push you to V3.

V3 — GitHub Action + Codemagic: orchestration leaves the VPS ​

mermaid
flowchart TB
  I[GitHub issue · from-po label] --> A[claude-code-action<br/>API, billed per token]
  A -->|spec, code, review| PR[Pull request]
  PR --> G{4 gates}
  G --> ST[staging]
  ST --> CM[Codemagic · signed iOS build]
  CM --> F[Firebase App Distribution]
  F -->|e-mail| PO[POs/BAs · iPhone]

Same chain, but the trigger is anthropics/claude-code-action@v1: no more cron or credentials on the VPS, logs in the GitHub UI, ~30-second reaction instead of the cron interval, and no contention with your personal quota. In exchange, every run is paid for.

Cost: V2 + API tokens. Orders of magnitude: a spec conversion, a few cents; an average feature's development + review, ~€1–5 depending on size and model. At 20 features/month: ~€20–100/month. Linux GitHub Actions minutes are covered by the Team plan (3,000 min/month included).

V4 and V5 — the Mac mini replaces Codemagic ​

mermaid
flowchart TB
  T[V4: VPS cron · V5: claude-code-action] -->|spec, code, review| PR[Pull request]
  PR --> G{4 gates}
  G --> ST[staging]
  ST --> MM[Mac mini · self-hosted runner + Xcode]
  MM --> F[Firebase App Distribution]
  F -->|e-mail| PO[POs/BAs · iPhone]

Same chains as V2 and V3, with the Mac mini as a self-hosted runner: Xcode compiles and signs, the Firebase upload leaves from the machine. In V4 the mini can also host a second subscription-based claude, relieving the VPS.

Cost: ~€700 once (≈ €29/month amortised over 24 months) + ~€5/month electricity — and above all maintenance: Xcode updates, certificate rotation, machine availability. That is CTO time, the only cost that shows on no invoice. Worth it when the Codemagic bill durably exceeds the amortisation, not before.

V1 ──(this week)──▶ V2 ──(shared quota becomes the bottleneck,
                          or native logs needed)──▶ V3
                                                     │
                        (Codemagic bill > ~€30/month, sustained)
                                                     ▼
                                                    V5

V4 is rarely the optimal step: if you buy the mini, the volume is there — and at that volume the API (V3/V5) beats the cron's quota contention.

Reminder from Decision 3, which conditions every iOS variant: the ios/ project does not exist yet — no folder, no Podfile, no port of the native quaddetector module. No euro spent at Apple or Codemagic produces a build before that work is done.


5. Roadmap in four waves ​

Ordered by dependency, not importance. Each wave unlocks the next.

Wave 0 — Make true what lies (1–2 days, alone, before everything else) ​

#ActionWhy now
0.1Move the org to GitHub TeamUnlocks everything else (Decision 1)
0.2Rewrite poc-tcg-collector/CLAUDE.md3 declared libs absent, iOS target inverted, fictional QA gate. Every agent reasons on it.
0.3Backend: glob instead of the hand-written test list6 test files never run, 3 of them on OCR
0.3bisFix the backend CI — it has NEVER been green0 successes in 100 runs since 2026-05-10. Requiring it as-is would freeze all merges (§6.3)
0.4Create the mobile repo's CIThere is none. typecheck + test: 20 lines
0.5gitleaks over both repos' full historyKnow before opening access to four people
0.6Purge the 107 merged branches (out of 239)A new dev will start from a dead branch
0.730-day retention + purge of archived scansUsers' photos kept without limit (Decision 7)
0.8Archive tgc-app and frontendTwo dead folders = two places to make mistakes (Decision 4)
0.9Clean .claude/clean-architecture copy, lp-conversion-skill, RN skills on the backend; settle tdd vs canon-tdd
0.10Back up Postgres — automated, restore testedMeasured: no cron job, no backup directory. The DB holds the catalog and 143,452 embeddings. Losing it means re-ingesting then recomputing every vector: hours to days without a product. The deployment guide says to back up the TLS certificates and ignores the database (§8.7).
0.11Resource limits per containerZero today across 12 services: under pressure the OOM killer may pick Postgres (§8.2)

Wave exit: the documents tell the truth, every test runs, CI exists on both sides.

Wave 1 — Put up the guardrails (2–3 days, before the team arrives) ​

#Action
1.1Create staging in both repos
1.2Rulesets A (main), B (staging), C (naming) — empty bypass list, you included
1.3CODEOWNERS + teams @snag-tcg/devs and @snag-tcg/product
1.4Required checks attached to the rulesets
1.5R1–R3 receivability job
1.6PR templates (dev and PO), auto-delete branches, squash only
1.7ESLint + lint-staged actually wired (the husky hook is a no-op)
1.8staging / production environments, CTO approval on production

Wave exit: nobody — you included — can modify main without passing the gates.

Wave 2 — Wire the distribution (3–5 days) ​

#Action
2.1Apple Developer account ($99/year)
2.2Firebase + App Distribution + product-owners group
2.3Codemagic connected to the repo, App Store Connect API key
2.4iOS pipeline: signed build → Firebase, triggered by a push to staging
2.5Android pipeline: npm run aab on the VPS → Firebase (unified channel)
2.6Register Nini's and Spleen's UDIDs (once)
2.7Automatic feedback: Firebase link commented on the original issue

Sequencing point, do not bypass: 2.4 triggers on staging, therefore after the wave-1 gates. A build that ships without passing tests puts the POs back in the regression-detector seat.

Wave exit: Nini and Spleen install and test autonomously, on their own iPhones.

Wave 3 — Open the team (first week at five) ​

#Action
3.1R4 receivability agent — visible, non-blocking
3.2Manu (review) — non-blocking
3.3Zaher (security) — blocking on critical/high only
3.4Discord webhook on three channels: #ci, #pr-review, #alerts
3.5spec-from-ticket skill in production (Decision 5)
3.6One hour with all four: the branch model, creating a branch from an issue (§7.3), and above all why — an understood guardrail is respected, an imposed one is bypassed

Wave 4 — Consolidate (second month and beyond) ​

#Action
4.1A single data path (§2.2-A) — the highest-value fix for the suite's informativeness
4.2Render tests on Scanner, CardDetail, Wallet
4.3Single contract schema, types generated on both sides — removes a class of bugs
4.4Mobile integration Jest config (react-native preset, intercepted HTTP)
4.5Self-feeding corpus from scan-debug-cases
4.6Nightly job: release build + emulator + Maestro
4.7Weekly Stryker on the 5 targets (§2.2-D)
4.8Executable .feature on the scan journey
4.9Diff coverage at 80%, warning first, then blocking
4.10Rewrite query.test.mjs as HTTP behaviour tests (zai's contribution)
4.11Atomic index swap + crossing test (§2.2-C)
4.12pytest in CI if tcg-app2 is kept
4.13Staging stack on the VPS
4.14Internal threat model, half a day with all four
4.15Delete the six dead screens in design-system/src/screens/ — measured: 68 imports of @/design, 1 of @/components, 0 of @/screens. Two sets of screens with only one mounted is a fork waiting to diverge
4.16Fix the sim / similarity naming — see the box below

The smell that 4.3 fixes, named. The server emits sim, the client expects similarity: two names for one concept, maintained by hand in two repos. Each side was consistent with itself, no test saw anything, and 100% of scans broke. It is the canonical example of a defect tests structurally cannot catch — a model disagreement, not a logic error. The single contract schema (4.3) is the fix; this is the diagnosis, kept because the same fault will recur with the next field added on the fly.


6. Infrastructure build-out guide ​

A runbook. Execute in order.

6.1 GitHub — organisation and rulesets ​

Ruleset A — main

RuleValue
Restrict deletions✅
Block force pushes✅
Require a pull request✅
↳ Required approvals2
↳ Dismiss stale approvals on push✅
↳ Require review from Code Owners✅
↳ Require approval of the most recent push✅
Require status checks✅ (§6.3)
↳ Require branches to be up to date✅
Require linear history✅
Require signed commits✅
Bypass listempty

Ruleset B — staging: identical, 1 approval, signatures optional. Throughput matters on the integration branch; irreversibility lives on main.

Ruleset C — naming, on ~ALL_BRANCHES:

^(main|staging)$|^(feature|fix|refactor|docs|chore|test|perf|poc|hotfix)/[a-z0-9]+(-[a-z0-9]+)*$

Ruleset D — tags refs/tags/v*: deletion and updates forbidden.

Do not put yourself on the bypass list. Disabling a ruleset in an emergency leaves an audit-log trace; a standing bypass leaves none. Observable exception versus silent hole.

Conventional-commit trap: with squash merges, it is the PR title that becomes the commit message. Lint the PR title, not the branch's commits.

Merge queue: pointless at five. Require branches to be up to date covers the same risk far cheaper.

6.2 CODEOWNERS ​

/.github/CODEOWNERS, mobile repo:

*                           @snag-tcg/devs

# Infra, CI, build, signing — CTO only
/.github/                   @kael
/android/                   @kael
/ios/                       @kael
*.gradle                    @kael
/scripts/                   @kael
/codemagic.yaml             @kael

# The agent configuration IS a security surface
/.claude/                   @kael
/CLAUDE.md                  @kael
/AGENTS.md                  @kael

# The harness changes under review, never in passing
/jest.config.js             @kael
/jest.setup.ts              @kael
/__tests__/                 @snag-tcg/devs @kael

# Product / engineering boundary
/design-system/src/design/  @snag-tcg/devs @snag-tcg/product

Backend, additionally: /docker/, /src/ingest/migrations/, /.github/workflows/ → @kael.

Why .claude/ sits under CODEOWNERS. The moment four people can push, skills/, hooks/ and CLAUDE.md become executable code that decides what the agents allow. A PR that quietly edits a review hook disarms the guard without touching a line of application code — and nobody reviews a .md.

6.3 Required checks ​

Mobile — everything to create; no CI exists.

JobCommandRequired onTarget
typechecktsc --noEmitstaging + main< 2 min
testjest --cistaging + main< 3 min
linteslint . (config to create)staging + main< 1 min
pr-titlecommitlint on the titlestaging + main< 10 s
receivabilityR1–R3 (§7.3)staging< 30 s
gitleaksdiff scanstaging + main< 30 s
build-debugassemble debug, no secretsstaging + main< 8 min
build-releasenpm run aab + keystoremain only—

Two mobile-specific traps:

  • The JS bundle. The job must go through npm run apk:* / npm run aab, never a bare gradlew assembleRelease — the latter silently embeds a stale bundle. A CI job building "by hand" would reproduce the bug already hit, but worse: validated by a green check.
  • The keystore. Exists only in build-release, triggered on main, never in a workflow started by a PR.

Backend — the CI is well designed and has never completed. Correction added 2026-07-28, after checking the results rather than just the ci.yml file:

last 100 runs of the "ci" workflow, all branches:
   92 failure · 8 cancelled · 0 success        (oldest visible: 2026-05-10)

The workflow is genuinely serious — typecheck matrix, docker-build, api-smoke on the compose stack, ingest-integration on a real pgvector. But it has never once been green, and nobody noticed because nothing depended on it.

Direct consequence for the roadmap: do NOT make these checks required as-is. Doing so would instantly freeze every merge. (Update 2026-07-28: the root causes were fixed — a before hook rebuilding shared dist/ under its siblings, 41 unbounded fetches, and a tessdata CDN 404 — and the CI went green for the first time in its history. The sequence below stands as the method.)

Corrected sequence:

OrderAction
1Fix the api e2e job — a test hangs to its 180 s timeout, blowing the job's 15 min cap
2Build the scan index in CI — without it, the assertion closest to the product is SKIP
3Test glob (W0.3), pytest, gitleaks
4Only then make the checks required

The lesson, worth more than the job. A well-written workflow and a passing workflow are different things, and only the second protects anything. Same for integration tests: the backend had the code, not the execution. Governance corollary, from wave 1: a status badge on every repo's README. A red CI must be visible without anyone going to look — that is what was missing for two and a half months.

Two high-value checks once the base is in place:

  • Diff coverage (diff-cover or equivalent), ~80% threshold on changed lines only. Far superior to a global threshold: that one punishes old files and is gamed by adding trivial code, whereas diff coverage asks exactly the right question — is what you are adding tested?
  • Mutation testing on the 5 critical modules (§2.2-D), as a weekly job that opens an issue. Never per PR: too slow, and the score fluctuates for reasons unrelated to the diff.

6.4 iOS pipeline ​

Assembly order:

  1. Apple Developer account — $99/year, at developer.apple.com, doable from Windows.
  2. App Store Connect API key — this is what lets Codemagic manage certificates unattended. Without it you manage certificates by hand: do not skip this step.
  3. Firebase — project, App Distribution enabled, product-owners group.
  4. Codemagic — connect the repo, paste the API key, target: signed build → Firebase upload.
  5. Trigger: push to staging. Not on PRs (no secrets in PRs), not on main (that is production).
  6. UDIDs — each iPhone registers once, at first launch of Firebase App Tester. Add the UDID to the Apple account, rebuild with the updated profile. Everything after is automatic. Apple limit: ~100 devices/year, far more than enough.

The trap that will cost half a day if ignored: the first build after adding a new UDID must be rebuilt — the old .ipa does not contain the new device in its provisioning profile. Plan it into each PO's onboarding rather than discovering it with Nini on the phone.

6.5 Android pipeline ​

Gradle runs natively on the Ubuntu VPS — no Apple account, no macOS. Same Firebase destination to keep a single distribution channel: a PO should not need to know which platform they are testing.

6.6 Securing the CI itself ​

Three rules, non-negotiable once agents read PRs.

1. Never pull_request_target with a checkout of the PR's code. That trigger runs with the base repo's context — hence with access to secrets — and checking out the proposed branch means executing untrusted code with the keys. Use pull_request: the default absence of secrets is the right property, not an inconvenience to work around.

2. An agent reviewing a PR reads hostile content — title, description, diff, comments. No privilege is required to put it there: opening a PR suffices.

  • the step that analyses: permissions: contents: read, no secrets;
  • the step that publishes: pull-requests: write and nothing else, from structured output.

Principle: the step that reads hostile content has no power; the step that has power reads no hostile content.

3. Actions pinned by SHA, not by tag — a tag is mutable. Dependabot on github-actions.

On notifications: never interpolate a PR title into a shell command. It is hostile text; it goes through an encoded JSON field.

6.7 Application security and GDPR ​

Already in place, and it is good work: token compared in constant time, explicit allowlist of the public POST surface, fail-closed when API_WRITE_TOKEN is absent, upload limits enforced twice (8 MB / 1 file / 4 fields), fully parameterised SQL, clean Android manifest (allowBackup="false", cleartext forbidden by default, a single exported activity), no committed secrets, and the CI genuinely tests the 401s.

What protects the system today is Tailscale, not that posture. On the day of public exposure, several defects flip at once:

#To fixWhen
S1CORS_ORIGIN ?? true reflects any origin → restrictive defaultW0, one line
S2Photo retention (Decision 7)W0
S3npm audit blocking on high + DependabotW0
S4Dedicated rate limit on the scan routes (10–30/min/IP) + trustProxy verifiedbefore public exposure
S5limitInputPixels on sharp — decompression bombbefore public exposure
S6Mandatory HTTPS, build-time host, tailnet out of the release variantbefore public exposure
S7Privacy policy + Data Safety + processing registerblocking for Play Store and App Store
S8Strict validation of case identifiers (regex allowlist)before public exposure

The rule that will decide the whole account architecture:

A secret embedded in a mobile app is not a secret.

An APK is a ZIP; R8 and Hermes complicate reading, they encrypt nothing. Any key injected at build time is public from the first install. The app therefore obtains only per-user tokens issued after authentication, stored in the Keystore/Keychain — not in AsyncStorage, which is not encrypted. And every authorisation is decided server-side, paywall included: an in-app subscription check is bypassed in ten minutes.

Two product points to settle, not technical ones: Pokémon implies a partly-minor audience (autonomous consent threshold at 15 in France, to handle before accounts open); and selling to shops makes you a processor of their data — a DPA will be required. The VPS at Hetzner, hence in the EU, is a commercial argument, provided it is documented.

Pentest: not now — it would be money wasted on an architecture about to change. Continuous free automation immediately (npm audit, Dependabot, gitleaks, semgrep, nightly ZAP baseline); external pentest triggered by the first of three events — public API, user accounts, first B2B contract — 4 to 6 weeks before, order of magnitude €5,000–15,000, retest included in the contract.


7. The team contract ​

This is the part to present. It fits in four pages.

7.1 Branch model ​

feature/* fix/* chore/* docs/*  →  staging  →  main
                                  (integration)  (production)
FromToApprovalMerge
feature/*, fix/*, chore/*, docs/*staging1 dev ≠ author (+ CODEOWNERS on sensitive paths)Squash, branch deleted
stagingmain2 + CTOMerge commit
hotfix/*mainCTO + 1 devSquash, then immediate back-merge into staging

The back-merge is the classic trap: a hotfix merged to main and never carried back reappears at the next release. Automate it — a workflow that opens the main → staging PR whenever they diverge. Never rely on someone's memory.

Why staging and not pure trunk-based: store latency makes a mobile release irrevocable in thirty seconds, and two code-pushing POs need a net between their work and production.

7.2 The four gates ​

GateContentBlocking?
1. ReceivabilityR1 branch name · R2 origin · R3 documentationYes — deterministic
2. Correctnesstypecheck, lint, tests, contract, gitleaks, buildYes — deterministic
3. JudgementR4 fit, Manu reviewNo — visible
Zaher securityYes, on critical/high only
4. HumanapprovalsYes

Never make an LLM verdict a required check for correctness. A model is non-deterministic. Made blocking on "is this code correct?", it produces false positives, the team learns to re-run it until green, and within three weeks it means nothing — worse, its presence justifies reviewing less. Sole exception: security, a narrow domain with extreme cost asymmetry (a false positive costs five minutes of discussion, a false negative costs an incident).

7.3 Receivability: nothing at random ​

No PR enters staging without a traceable origin. No orphan code, no "while I was at it", no branch whose name does not say what it contains.

CheckDecided by
R1The branch prefix matches the actual diff — docs/* does not touch src/regex
R2Tied to a GitHub issue, a doc/specs/ spec, or a doc/fixes/ reportregex
R3doc/CHANGELOG.md present if src/ changes; guide/en ↔ guide/fr parityregex
R4The diff answers that origin — no less, no more (scope drift)agent

Three out of four are decided mechanically. Do not hand an LLM what a regex decides better, faster and cheaper.

The detail that makes it painless for the POs: GitHub can create a branch from an issue — the Create a branch button on the issue page. The name is pre-filled in the right format and the link is automatic. R1 and R2 become one click. It is the first gesture to teach them; all the traceability follows for free.

Explicit escape hatch: the no-issue label allows a PR without an origin, only for a trivial chore/*. Visible in the history. An observable exception beats a bypassed rule.

7.4 Writing a good ticket ​

Nini and Spleen write in natural language. No formalism to learn. This list suffices:

  • Name of the feature
  • Goal — the why, in one sentence
  • Value — for whom, what benefit
  • Location — which screen, which section
  • Expected UX — behaviour, visual states
  • Competitor references — screenshot or link
  • "It is done when…" — 2 or 3 sentences. The most important field: without it, nobody knows when to stop or what to test.
  • Priority — blocking / important / nice-to-have

The spec-from-ticket skill (§10.4) converts this into structured Given/When/Then. The POs never see that format.

7.5 Notifications ​

Notify only what requires a human action. A channel that receives every green check is muted within a week — including the day something burns.

Event#ci#pr-review#alertsE-mail
PR not receivable✅ author
PR ready for review✅
Scope drift (R4)✅
Security block✅ @here✅
staging → main PR✅✅
Production deploy awaiting approval✅✅
Deploy failure✅ @here✅
Build available for testing✅ Nini + Spleen
Routine green check✅

E-mail is reserved for what must leave a trace: production, security, releases, builds to test. Three to five per week at most — beyond that, the first line's rule is not being honoured.

7.6 What not to do ​

AvoidWhy
Making the agent blocking on correctnessFalse positives → re-runs until green → the check means nothing
Handing R1–R3 to an agentA regex does it better, faster, cheaper
Requiring 2 approvals on stagingA bottleneck at five; the team will learn to self-approve
Putting yourself on the bypass listA guardrail with an admin exception is a guardrail for the others
A global coverage thresholdGamed by adding trivial code. Diff coverage asks the right question
Notifying every green checkThe channel is muted within a week
Distributing to POs before the gatesYour POs become regression detectors
Building guardrails before wave 0You industrialise green checks over false premises

8. Holding the load: cache, queue, redundancy ​

None of the three source documents covers this, and neither does the documentation repo: no Kubernetes, no load balancing, no queue, no scan cache, no SLA. Section written 2026-07-28 from measurements taken on the production VPS, not from general principles.

8.1 The measurement that governs everything else ​

Three sequential /scan-lab calls on the VPS, with a real photo:

0.83 s   0.76 s   0.43 s

Then ten scans fired in parallel:

0.83 → 1.23 → 1.64 → 2.08 → 2.55 → 2.96 → 3.39 → 3.80 → 4.26 → 4.66 s
wall clock: 4.71 s

A perfectly linear staircase. Parallelism buys strictly nothing: the tenth request waits for the other nine. Real throughput is ≈ 2.1 scans/second, regardless of client count.

What this means for the 1,000-simultaneous-users question:

Sustained throughput≈ 2.1 scans/s, i.e. ~7,600/hour
1,000 simultaneous scansthe last one served waits ≈ 8 minutes
Client timeout10 s
Consequenceeverything beyond ~21 concurrent requests fails client-side — while the server keeps computing for clients that are gone

The system does not collapse: it serves 21 people and fails the other 979, burning CPU for nothing all the while.

8.2 Where the bottleneck is, exactly ​

In order of breakage:

  1. The embeddings sidecar serialises. That is the staircase. A single DINOv2 inference worker.
  2. OCR is an explicit singleton — the code says so: "the worker is a singleton (queues requests internally), a stuck recognition would block every future /scan call". Affects only /scan, not the /scan-lab the app uses, but it is a second serialisation lying in wait.
  3. No resource limits on any container — measured: deploy/replicas: 0, limits: 0 across 12 services. Under pressure the OOM killer picks an arbitrary victim, potentially Postgres.
  4. The rate limit protects nothing: 600/min global, no per-route limit. One client can saturate the machine without ever hitting it.
  5. Postgres/pgvector is not the problem — the HNSW ANN answers in 1–5 ms.

8.3 The immediate win, and it is free ​

Measured on the VPS: 4 cores, 7.5 GB with ~4 GB free. The sidecar uses 496 MB.

Running 3 embedding workers instead of one multiplies throughput by ~3, on the current machine, buying nothing. By far the best effort-to-gain ratio in this section — and it needs neither Kubernetes, nor a load balancer, nor a rewrite.

nginx already has an upstream block: it knows how to spread load. Docker Compose knows how to run replicas. Adding API instances, by contrast, is useless while the sidecar serialises: the API is not the bottleneck, it waits.

Ceiling of this approach: 3–4 workers (RAM), and 4 cores shared with Postgres and the rest. Beyond that: a bigger machine, GPU, or horizontal scale.

8.4 Scan cache — what works and what changes nothing ​

A scan is a pure function of the image: cacheable in theory. In practice, two photos of the same card are never bit-identical — a cache keyed on the image hash would have a near-zero hit rate in real use.

Cache whatExpected hit rateVerdict
The scan result, by image hashnear zero (photos all differ)❌ useless
The embedding, by image hashlow — mostly retry/debug⚠️ marginal
Card metadata, by card_idhigh — popular cards get rescanned✅ yes
Top-K, by quantised embeddingvariable, risks serving a wrong neighbour❌ dangerous

Recommendation: no cache on the scan itself. Cache post-match metadata (cards + images), short TTL, in memory or Redis.

And above all: do not believe a cache solves the load problem. The bottleneck is inference, not reads. A perfect metadata cache would leave throughput at 2.1 scans/s.

The real volume win is elsewhere: avoiding the useless scan. App-side dedup of a same-photo retry removes real requests, which no server cache can do.

8.5 Queue: bound it, and refuse fast ​

Today there is none. Requests pile up in sockets, latency is unbounded, the client gives up at 10 s — and the server keeps working for it. It is the async boundary already flagged in the audit, but it becomes a capacity problem at volume.

What is needed, in this order:

  1. An explicit maximum concurrency, equal to the number of inference workers.
  2. Beyond it: immediate rejection with 429 + Retry-After — no queuing. Better to say "come back in 3 seconds" than to hold someone 8 minutes into a timeout.
  3. Cancel the work when the client is gone: propagate the AbortSignal down to the sidecar call. Today an abandoned request still costs a full inference.
  4. A per-route limit on the scan paths (10–30/min/IP), distinct from the global one — action S4 in §6.7.

The asynchronous queue — scan → job id → notification — is not recommended now. It changes the API contract and the UX (the user waits for their result; they do not check an inbox). Reserve it for bulk scanning (the app's "Bulk" mode), where async is natural.

8.6 Docker, Kubernetes, load balancing ​

Straight answer on Kubernetes: no. Not now, and probably not for a long time.

What k8s brings: multi-node orchestration, autoscaling, rolling updates, self-healing. What it costs: a cluster to operate, by a solo CTO, in a team of five of whom two are not developers. The ratio is unfavourable, and stays so until several of these hold: multiple teams, multiple permanent environments, genuinely needed autoscaling, someone whose job it is.

Compose already does the essentials: 11 restart policies, 6 health checks. What is missing is not an orchestrator, it is:

  • per-container resource limits — zero today across 12 services;
  • replicas on the sidecar — which Compose can do.

The next scale, if the need arrives: a second VPS with Docker Swarm, or managed inference to move the bottleneck off your machine. Both are an order of magnitude cheaper to operate than a cluster.

8.7 Failure recovery and SLA ​

The gravest hole in this whole document ​

crontab       →  no backup job
/home/kael    →  no backup directory

Postgres is backed up nowhere. It holds the full catalog, 143,452 embeddings, and soon users' collections. The deployment guide in the documentation repo says to back up caddy_data — TLS certificates, rebuildable with one command — and says nothing about the database.

Losing that volume means a full re-ingest then recomputing 143,452 embeddings. Hours, possibly days of compute, during which the product does not work.

This is, in my view, the most urgent action in the entirety of this playbook — ahead of the guardrails, ahead of CI. (Update 2026-07-29: a first pg_dump -Fc backup now exists at /home/kael/backups/, integrity-checked. The automated cron + tested restore remain to be built.)

Monitoring: absent ​

/metrics exposes Prometheus, the CI tests it… and nothing collects it. No Prometheus, no Grafana, no alerting. An outage will be discovered because a user complains.

On the SLA ​

Promise no SLA while there is no redundancy. One VPS, zero replicas: everything is a single point of failure, and restart policies cover a process crash, not a machine failure.

An SLA you cannot honour is worse than no SLA — especially in B2B, where it becomes contractual and engages your liability. The healthy progression is: measured internal SLO first, external commitment second, never the reverse.

The first shop contract will demand it. It is already one of the three triggers of the external pentest (§6.7); it is also the trigger for redundancy.

Order of work ​

#ActionWhy this rank
R1Automated Postgres backup + tested restoreA backup never restored is not a backup. Nothing else matters first.
R2Per-container resource limitsStops the OOM killer choosing Postgres as its victim
R3Metrics collection + alerting/metrics already exists; only the collector is missing
R43 embedding workers (§8.3)×3 throughput, free
R5Bounded queue + 429 rejection (§8.5)Turns a collapse into a legible degradation
R6Machine redundancyOnly here — and only if an external commitment demands it

9. Measuring progress ​

§2.3 dismantles the zai audit's scores because they rest on false measurements. Dismantling a metric without proposing another leaves a void exactly where you will want to show the team that the work is advancing. Here is what fills it.

The rule that governs this section: any metric that drives a decision must be produced by a job, not by a document. A number in a report is a photograph that ages; a number in a CI is a measurement. It is also what makes this page verifiable without re-reading me.

9.1 The six indicators that matter ​

Six, not twenty: a dashboard nobody looks at is no better than a muted Discord channel.

#IndicatorToday (measured 2026-07-27/28)TargetProduced by
M1Repos with a green CI on main1 / 3 — mobile has no workflow3 / 3README badge + gh run list
M2Test files executed / existingbackend 35/37 · mobile 48/48100%, guaranteed by testWiringtest job
M3Diff coveragenot measured≥ 80%, warning then blockingPR job
M4Screens covered by a render test0 / 223 (Scanner, CardDetail, Wallet), then growthmobile test job
M5Mutation score on the 5 targetsnot measuredestablish the baseline, then no regressionweekly job
M6PRs receivable first try (R1–R3)n/a≥ 90% after one monthreceivability job

How to read them. M1 and M2 are binary: held or not, fixable in one go. M3 and M4 are trajectories — the slope matters, not the absolute value. M5 has no pre-set numeric target: a mutation score compares only to itself, and setting "80%" without a baseline produces test-stuffing. M6 measures process quality, not code: if it stagnates low, the gate's documentation is bad, not the team.

9.2 What not to measure ​

AvoidWhy
Test count1,757 tests protecting the wrong code is this document's starting point. Volume is not a guarantee.
Global coverageGamed by adding trivial code, punishes old files. Diff coverage asks the right question: is what you are adding tested?
Velocity, points, PRs per weekMeasures motion. Five people who see each other need no proxy to know who is advancing.
A global score out of 10Precisely the zai audit's mistake: it aggregates incomparables and hides bimodality (§2.2-B).

9.3 The ritual ​

A monthly review, fifteen minutes, over the six lines M1–M6 as CI reports them — not as they are narrated. If an indicator is not produced automatically that day, it does not count, and we say so.

Only one document gets updated: that table. The three source audits stay frozen; they are dated photographs.


10. Skills: purge, complete, create ​

10.1 What to delete ​

Both repos have a strictly identical .claude/ — 16 skills, 6 agents, 34 commands — copied as-is from a React Native app to a Python/Node backend. The purge is in wave 0 (§5); here is the exact list, without which the task is not executable.

TargetRepoReason
skills/clean-architecture copy/bothLiteral duplicate folder, with references/ the original lacks. Two contradictory sources of truth on architecture.
skills/lp-conversion-skill/bothLanding-page copywriting. Nothing to do with either repo.
skills/react-native, react-native-best-practices, react-patterns, web-performancebackendThere is no React in the backend.
skills/api-design-tsmobileThe app serves no API.
commands/scaffold-android.md, commands/import-cards.mdbackendOff-topic.
tdd or canon-tddbothTwo TDD skills loaded in parallel. Keep canon-tdd (it has the references/) — otherwise you arbitrate between two doctrines on every task.
typescript-patterns or typescript-craftsmanshipbothBroad overlap, same arbitration.
Root CHANGELOG.md, PROGRESS.md, TODO.md, DONE.mdbackendIts own CLAUDE.md declares them "banned"; doc/ is canonical. They are still there.

Reminder from §6.2: .claude/ goes under CODEOWNERS the same day. The moment four people can push, these files are executable code deciding what the agents allow — and nobody reviews a .md.

10.2 What is missing, by impact ​

  1. Mutation testing — nothing, anywhere. The one addition that moves you from "reviewing lines" to "trusting the harness".
  2. Property-based testing (fast-check) — pHash, Hamming distances, embeddings, quad detector. Exactly the domain where a hand-picked example proves nothing.
  3. RN testing (@testing-library/react-native) — 22 screens, 0 render tests (M4).
  4. Python — src/embeddings/ is Python with one test file and no CI.
  5. Executable BDD/Gherkin — one .feature exists, no runner executes it.
  6. Mobile release / versioning — signing, versionCode, Play tracks. Nothing. See also the irreversible pre-publication decisions.

10.3 The three craft skills — the foundation, whichever variant you pick ​

These come before all the others, and they are independent of the workflow chosen in §4.2: whether the agents are driven by a cron or by the GitHub Action, they read the same skills.

Why replace rather than supplement ​

skills/clean-architecture → "adapted to this Next.js 15 App Router + Prisma + React 19 project"
skills/clean-code         → "applied to this TypeScript / Next.js / React project"
skills/react-native       → announces React Navigation v6, zustand, SQLite

None of those descriptions matches Snag. The mobile app is bare React Native with no React Navigation (hand-rolled navigation), no zustand, no SQLite — measured. The backend is Fastify + pg, no ORM. An agent reading "Clean Architecture for this Next.js App Router project" applies web-server patterns to a mobile app.

It is the same pathology as the CLAUDE.md of §2.2-E: a false document is worse than an absent one, because it is trusted. These three generic skills must be deleted (§10.1) and replaced by the three below, written from the real architecture.

snag-architecture — the foundation the other two read ​

This is not a skill you invoke: it is the reference both snag-implement and snag-review load, so that a developer and their reviewer never reason from two different models.

What it settles:

  • The real stack, measured and dated — RN 0.73.6 bare, Hermes on, New Architecture off, hand-rolled navigation, @/design as the design system's only entry point. Backend Fastify 5 + pg + pgvector, parameterised SQL, no ORM.
  • One data path only. The rule that falls out of §2.2-A's worst defect: there is exactly one source for a given piece of data. Introducing a second way to read cards is a review rejection, not a debate.
  • Screens do not orchestrate. ScannerScreen currently drives the vision pipeline (crop, detection, backend calls): that is the layer violation not to reproduce. A screen renders and delegates.
  • Extracting to test is a symptom, not a pattern. If logic must leave a component only to become testable, the component was doing too much. The skill says so explicitly, because the repo contains nine test files that test extracted functions rather than screens.
  • One contract, one definition. A field exchanged between client and server is defined once, and the types are generated on both sides. That is the sim/similarity lesson turned into a rule.

snag-implement — the development skill ​

Loaded by: any agent or developer writing production code.

What it enforces:

  • The canon-TDD loop as practised here — test list, red, green, refactor — and the expected assertion level: behaviour, never implementation.
  • Where code goes, by delegation to snag-architecture. No placement decision is left to improvisation.
  • The traps already paid for, each with its counter-measure — these are what make the skill valuable, because they follow from no general principle:
    • a require() inside a try/catch protects nothing in a Hermes release build;
    • the JS bundle must be regenerated before any release build, or the artefact ships stale code;
    • a new test file must be wired into a script, or it will never run;
    • a multi-line assertion over a read file must be line-ending agnostic, or the suite is green on Linux and red on Windows;
    • every fetch carries a timeout.
  • The test altitude per target: a screen is tested by rendering, a pure function by its bounds, a seam by an integration test. The skill refuses the workaround of extracting code in order to unit-test it.

snag-review — the review skill ​

Loaded by: the review agent (Manu) and any human reviewer.

What blocks — short, and only the deterministic or the severe:

  • a second source for data that already has one;
  • an assertion on an implementation string (the query.test.mjs case: assert.equal(whereSql, "WHERE …") breaks on every refactor without any behaviour changing);
  • a hand-enumerated list of test files;
  • a fetch or external call without a timeout;
  • SQL built by concatenation;
  • a secret, or a token expected on the client side.

What is commented without blocking: naming, function size, conscious duplication, architecture suggestions. That separation mirrors the four gates (§7.2) — the deterministic blocks, judgement informs.

What the skill forbids the reviewer: asking for more tests without saying which, and demanding an abstraction with no second implementation in sight. The repo already carries six interfaces with a single implementation (§2.2-A); the skill also exists so that a seventh is not added.


10.4 The six process skills ​

By return on effort. Each encapsulates a decision already arbitrated in this document. They come in addition to the three craft skills of §10.3, not instead of them.

spec-from-ticket — the most valuable ​

Trigger: an issue labeled from-po, or a manual command. Input: Nini's or Spleen's natural-language description. Output: a structured issue — user scenario, acceptance criteria in Given/When/Then, location, explicit out-of-scope, UX states (loading / error / empty), technical constraints, dependencies. Rules: never invent a missing criterion — list gaps as "questions for the PO". Out-of-scope is mandatory: it prevents the scope drift R4 would otherwise have to catch.

pr-receivability — R1 to R3 ​

Trigger: PR opened or updated. Nature: deterministic, not an agent. A script. Output: a single comment updated in place (never a new one per run), with, for every failure, how to unblock. A guardrail that explains the way out is respected; one that just says "rejected" is bypassed — all the more with non-developer contributors.

scope-drift — R4 ​

Nature: agent, non-blocking. Evaluates: fit to the ticket, scope drift, completeness, honesty of the PR title (which becomes the commit message under squash). Does not evaluate: code quality — that is Manu. Mixing the two produces a comment nobody reads.

release-mobile ​

Chains: verify staging is green → JS bundle → iOS build (Codemagic) and Android (VPS) → Firebase upload → notify the product-owners group → comment the issues included in the build. Hard-coded guardrail: refuse to build if the JS bundle generation did not run. That is the bug already hit; it must not be able to recur by omission.

gdpr-check ​

Trigger: a diff touching storage, upload, logs, or a new user field. Checks: declared retention period, legal basis, no personal data in logs, consistency with the processing register.

harness-guard ​

Trigger: a diff touching jest.config, package.json (test scripts), .github/workflows, .claude/. Checks: no hand-enumerated test list (glob mandatory), no skip/todo without a linked issue and an expiry date, no threshold lowered without justification. Reason to exist: it is the skill that stops the harness degrading silently — the most likely failure mode with five people.


11. Open questions ​

What neither the three documents nor this reconciliation settles, and which needs an answer from you.

  1. Unifying the ticket channels. synthese-infra raises it without resolving it: five channels (Slack, GitHub Issues, Discord, Hermes, OpenClaw). My recommendation: GitHub Issues as the single source of truth, the other channels being inputs converted by a bot. Otherwise R2 traceability is untenable, and it carries all of §7.3. (Resolved in practice by §4.2: the issue form is the sole entry; Slack/Discord become output-only.)

  2. The fate of tcg-app2. Alive or archived? The status quo — a Python backend at ~2% coverage, no CI, last commit 19 May — is untenable at five.

  3. Who approves when you are away? With 2 approvals + mandatory CTO on main, you are a single point of failure. Plan it before needing it.

  4. A staging environment on the VPS. There is only one today, so a PR can only be exercised against production. The only item in this document that requires machine resources.

  5. Scan quota and cost per user. The paywall promises "unlimited scans" on Pro. What does a scan actually cost (CPU/GPU on the VPS)? Without that number the pricing model is a hypothesis — and it also sizes rate limit S4.

  6. Do Nini and Spleen actually push code? If their contribution is really content and copy, much of the CODEOWNERS weight can be lightened. Ask them rather than assume.

  7. The Snag rename — how much further? Partly answered. The product name was decided on 2026-07-29 and the organisation was renamed to snag-tcg the next day. Still carrying the old name: the repository names (poc-tcg-collector is not a POC any more), the in-app brand ("Passer à SKN Pro"), the store display name ("TCG Collector"), and the applicationId — com.skntcg.collector is obsolete and that identifier is permanent at first publication. Repository renames are reversible (GitHub keeps a redirect) and can wait; the identifier cannot. Decide it before anything is uploaded to a store.


Appendix A — Budget ​

ItemCostWhenNecessity
GitHub Team (5 seats)~$20/monthW0Blocking for everything else
Apple Developer Program$99/yearW2Blocking for iOS
Firebase App DistributionfreeW2—
Codemagicfree then usage-basedW2Alternative: Mac mini €600–800 once
Claude API tokens (variants V3/V5 of §4.2)~€20–100/month at 20 features/monthwhen the shared quota becomes the bottleneckOptional — the subscription cron (V2/V4) costs €0 marginal
Staging VPS~€10–20/monthW4Recommended at five
External pentest€5,000–15,000laterTriggered by an event, not the calendar

Recurring at start: about €40/month, plus $99/year. Compare with the cost of a single day lost, five people strong, to a regression CI would have caught. The detailed per-workflow-variant pricing (V1 to V5) is in §4.2 — that is the authoritative table for the budget/cash arbitration.


Appendix B — Presentation cards ​

Six slides for the team meeting. One message per slide.


1 · Where we stand

1,757 green tests. A serious backend CI. A design system whose contrast is build-verified. And: no branch protection, no mobile CI, no way for a PO to test. What holds the project together today is that one person touches it. We are five on Monday.


2 · The branch model

feature/* → staging → main On staging: 1 review. On main: 2 + the CTO. main is always shippable. Production deployment is an approval, not a merge.


3 · The four gates

Receivability — where does this code come from? (blocking, automatic)Correctness — does it work? (blocking, automatic)Judgement — is it good? (visible, non-blocking — except security)Human — someone read it (blocking)

Machines block on what can be measured. Humans decide what must be judged.


4 · Nothing at random

Every PR is tied to an issue, a spec, or a fix report. The branch name says what it contains. The docs follow the code.

The gesture to remember: on a GitHub issue, the Create a branch button. Correct name and automatic link. One click, and 90% of the rule is honoured.


5 · For Nini and Spleen

Write your tickets normally, in French. No format to learn. One field matters more than the rest: "it is done when…". You will get an e-mail for every testable build, installable on your iPhone in one tap. And you can push code: staging exists so that being wrong costs nothing.


6 · The rule that sums it all up

Zero manual execution. Not zero human judgement.

Everything measurable runs by itself, on every PR, with nobody thinking about it. What must be judged — accepting a golden, arbitrating a mutant, setting the scan threshold — stays ours.


Appendix C — The real contradictions ​

Readable in one sitting; self-contained — nothing to look up elsewhere.

The two audits do not say the same thing. On six points they clash head-on, and on four of them following the wrong one would have you working for nothing. Here is what separates them, what the measurements say, and what I conclude.


1. Does the mobile repo have 607 tests or 1,757? ​

The zai audit builds its scores on a count: 607 test cases, 127 suites, 55 test files for 101 source files.

I re-ran the suite the same day:

Test Suites: 1 skipped, 54 passed, 54 of 55 total
Tests:       4 skipped, 1757 passed, 1761 total

And on disk: 48 test files, 119 source files. All four numbers are wrong, one by a factor of three.

What I conclude. zai's qualitative findings remain largely valid — it saw true things, including one I missed. But its scores derive from those numbers, so 7.5/10 and 4/10 are not a state of play and must serve neither as tracking indicators nor as meeting material.

The lesson outlives this report: any metric that drives a decision must come out of CI, not out of a document. A number in a report is a photograph that ages; a number in a job is a measurement. It is also the only way to make the roadmap verifiable without re-reading me.


2. Mobile architecture: 8/10 or structural defect? ​

zai scores the design 8 out of 10, with "well-controlled layer separation, minimal accidental coupling".

I measured the actual imports:

src/data/repos.ts   → 6 interfaces, 6 implementations, all named Mock*
11 screens          → import @app/data/demo/catalog directly
design-system/src   → 68 imports of @/design, 1 of @/components, 0 of @/screens

In other words: a repository abstraction fully tested and never used by the application, and a demo catalog — containing the portfolio computation, i.e. the money — used everywhere. Plus six dead screens in design-system/.

What I conclude, and it is this appendix's most important point: both are right, at different altitudes.

zai looks inside the modules: literal types, dependency injection, domain-aligned naming. At that level, 8/10 is deserved; I confirm it without reservation.

I look at the wiring between modules: which path the code actually takes.

A repo can be excellent module by module and fragile in its assembly. That is precisely the defect no aggregate score captures, and it is the one that produced your bugs. Do not be reassured by the 8/10 — but do not conclude the code is bad either. It is well written and badly wired.


3. Test discipline: good, or split in two? ​

zai gives mobile 8/10. That is an average, and the average crushes the useful information.

The test-to-source file ratio, commit by commit:

fix(scanner): route mobile scans through SKN   tests=7  src=9
fix(scanner): simplify scan mode selection     tests=4  src=3
---
feat(screens): redesign every screen           tests=1  src=27
fix(ui): resolve the review findings           tests=2  src=24

The scan pipeline sits at one-to-one — the signature of a genuine red-green cycle. The UI sits at one-to-twenty-seven — a bulk landing.

What I conclude. Do not ask the team for "more TDD"; that is an instruction without a target. Ask for render tests on three screens — Scanner, CardDetail, Wallet — exactly where the ratio collapses, and where the vision and the money flow.


4. The "race condition": the one case where following zai wastes your time ​

zai reports a TOCTOU in the index reload and recommends "proper locking":

ts
if (scanIndexReloading) return;
scanIndexReloading = true;

Node is single-threaded with an event loop. No preemption can occur between these two synchronous statements. So this is not a TOCTOU, and a mutex would change strictly nothing.

The risk is real — but elsewhere: a scan request that starts before the index swap and finishes after it can read an inconsistent state, or mix two generations.

What I conclude. The right fix is not a lock but an atomic reference swap — build the new index fully, then reassign in one statement — plus the guarantee that each request captures the reference exactly once at the start. And a test that crosses a reload with an in-flight request: none exists.

The most instructive case of the six: a model-produced audit can name a real symptom with the wrong vocabulary and prescribe an inoperative remedy. The filter is someone who knows the execution model. That is your role, and it is why none of these three documents stands alone.


5. iOS or Android? Three answers, one of them in your code ​

WhereWhat it says
synthese-infraiOS first, "more bankable"
claude_auditAndroid first, for lack of hardware
poc-tcg-collector/CLAUDE.md"iOS is not on the roadmap — don't add iOS-specific code paths"

The business decision settles it: iOS. But that means the file every agent reads before every task says the exact opposite of your strategy.

And it is not its only error: it declares three libraries as the project's foundation — React Navigation "pinned", zustand, react-native-sqlite-storage — measured absent from package.json. It also announces a Maestro/Detox QA gate of which neither the tool nor the folder exists.

What I conclude. Fixing that file is the cheapest, highest-yield action in the whole playbook: one hour, before anything else. While it lies, every agent — and soon every new developer — reasons from false premises. A false document is worse than no document, because it is trusted.

Nuance to keep: iOS priority does not mean abandoning Android. Gradle runs on your VPS, free, with no Apple account and no macOS. Android stays the fast verification path; iOS becomes the PO distribution path.


6. "Zero manual testing": exactly how far? ​

zai devotes an entire chapter to the zero-manual goal, at 100%. I listed three things that must not be automated.

What I conclude: the agreement is about execution, the disagreement about judgement — and that distinction is what saves the automation rather than limiting it.

Automating execution 100%: yes, without reservation. No test step may depend on someone remembering to run it — that is already what left six backend test files never executing.

Three decisions must stay human, or automation carves the mistakes in stone:

  • accepting a golden diff — regeneration is a command, acceptance is a review; a golden approved blind turns a bug into a reference;
  • arbitrating a surviving mutant — some are semantic equivalents; killing them costs more than the guarantee gained;
  • setting the scan similarity threshold — a product trade-off, precision against recall. Measured automatically, decided humanly.

The phrase to repeat to the team: zero manual execution, not zero human judgement.


And a seventh disagreement that is not one: the blind spot ​

synthese-infra describes a complete chain — ticket, build, signing, distribution, Nini's iPhone — without a single test having to pass anywhere.

It is not an explicit contradiction with the two audits. It is a blind spot, and the costliest of the seven: shipping unverified code faster to non-technical people means turning your POs into regression detectors. It is the most expensive and demoralising job you could give them, and they cannot tell a bug from an unfinished feature.

That is the reason — the only one — why wave 2's iOS build triggers on staging and never on a feature branch. Distribution comes after the gates, not instead of them.


What to retain before turning off the light ​

Of the six contradictions, only one is an outright error (the numbers), only one leads to a useless fix (the lock), two are altitude differences where both sides are right (the architecture, the test discipline), and two are decisions that belong to you (iOS, the automation boundary).

None of the three documents should be discarded. But none should be applied as-is — and that is exactly why this playbook exists.