[
  {
    "key": "A0",
    "title": "Box prep on both boxes",
    "description": "## A0 \u00b7 Box prep on both boxes \u00b7 S \u00b7 W0 \u00b7 box-only (S1-rev)\n**Context.** Production runs on `server.ecomprofits.io` (48 cores / 250 GB / 1.2 TB free, Plesk + Portainer, production Supabase on the same box); staging on `server.rescale.media`. Everything the deploy pipeline assumes about the boxes is created here, once, by hand.\n**Read first.** Architecture doc \u00a75, \u00a76, \u00a79, \u00a710; ecomprofits `.github/workflows/docker-build.yml` (the SSH/compose pattern already used on the production box); `project_rescale_plesk_server` notes (never edit containers in the Plesk GUI; Portainer = viewer).\n**Deliverables.** On `server.ecomprofits.io`: `mkdir -p /opt/skynet` (git clone of the monorepo, branch `main`, deploy key read-only); `docker login ghcr.io` with a `read:packages` PAT; `mkdir -p /var/lib/skynet-backups/{daily,pre-deploy}` owned by the backup account, mode 750; Plesk vhost `app.rescale.media` \u2192 proxy `127.0.0.1:38000` with Let's Encrypt, HSTS, `client_max_body_size 512m` (DNS still points at the old host until go-live \u2014 issue the certificate via the staged hostname or DNS challenge); a Plesk Scheduled Task (daily 02:30) syncing `/var/lib/skynet-backups/daily` to the Hetzner Storage Box prefix `skynet/`; record a baseline `docs/ops/box-baseline-ecomprofits.md` (`nproc`, `free -g`, `df -h`, `docker stats --no-stream`, `uptime`). On `server.rescale.media`: `/opt/skynet-staging` clone, GHCR login, vhost `staging.app.rescale.media` \u2192 `127.0.0.1:38001` with TLS, staging dumps go to the existing Plesk `private/pgdump` sweep. Both: an SSH deploy user with the GitHub secrets `SERVER_HOST_PROD/STAGING`, `SERVER_USER`, `SERVER_SSH_KEY`, `SERVER_DEPLOY_PATH_PROD/STAGING` set.\n**Acceptance.** Post in the task: `ls -ld /opt/skynet /var/lib/skynet-backups/*` on prod; `curl -I https://app.rescale.media` (with `--resolve` to the box) \u2192 502 from Plesk (vhost exists, nothing behind it yet) and `curl -I https://staging.app.rescale.media` \u2192 same on staging; `docker pull ghcr.io/rescale-media/ecomprofits:latest` succeeds on both (login works); the baseline doc merged.\n**Out of scope.** Any container; DNS flip (X7).",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W0"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "A10",
    "title": "Agent runtime on Postgres `studio` + engine API parity",
    "description": "## A10 \u00b7 Agent runtime on Postgres `studio` + engine API parity \u00b7 A \u00b7 L \u00b7 S3-W1\n**Read first.** D31; `console/src/db/client.ts` (DDL) + `dal.ts` (\"the Postgres seam\"); every `/api/engine/*` and `/api/research/*` route; `console/scripts/night-shift-runner.mjs`.\n**Deliverables.** Migration `db/migrations/unified/studio/001_baseline.sql` (every SQLite table \u2192 Postgres with `workspace_id`, JSON columns as `jsonb`, `session_events` append-only); DAL rewritten on `pg` (same function signatures); data migration script SQLite \u2192 Postgres with row-count verify; engine API unchanged in shape (`x-cron-token` replaced by the Skynet service token, one adapter); the console runs as service `studio` in the stack (Node 24, `cpus: 4`, `mem_limit: 8g`) reading the new DB; `pnpm --filter agent-runtime test` + `test_studio_pg` parity suite (every DAL function on both backends over the same fixture).\n**Acceptance.** Parity suite green; migrated data verified; a session starts, streams, gates and completes on Postgres.",
    "labels": [
      "lane:A",
      "size:L",
      "wave:S3-W1"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "D31"
    ],
    "links": []
  },
  {
    "key": "A11",
    "title": "Runtime assets, night-shift on the stack, workspaces, subscription binding + rotation",
    "description": "## A11 \u00b7 Runtime assets, night-shift on the stack, workspaces, subscription binding + rotation \u00b7 A \u00b7 L \u00b7 S3-W2\n**Read first.** D31; `engine/` layout (skills, doctrine, memory, playbooks), the repo-root business workspace dirs `dropshipping/`, `supplements/`, `strategic-loop/`, `engine/automation/*`, `night-shift-jobs.cloud.json`, `registry.mac.ts`; `accounts.ts` (`CLAUDE_ACCOUNT_{i}_NAME/TOKEN`), `/admin/claude-usage`.\n**Deliverables.** Skills, doctrine, playbooks and the business workspace dirs (`dropshipping/`, `supplements/`, `strategic-loop/`) become repo-tracked runtime assets mounted read-only into the `studio` service (no laptop copies; `/evidence-layer` + `_doctrine/evidence-contract.md` included, its MCP transport bound to A18b's `/mcp/pubmed`); every loop (`npt-build-loop`, `dream`, `lint`, `backlog`, `loop-closer`, `frankenstein`, `trend-watcher`, `volume-trigger`, `backup`) registered in one scheduler on the stack (`studio.cron_jobs`, UTC schedules recorded, per-job limits); the Claude subscription runner = binding `studio.agent` in `core.model_bindings` with the account rotation intact and usage posted to `usage_events`; `/admin/claude-usage` data in the Skynet admin; `node services/agent-runtime/scripts/run-night-shift-smoke.mjs`.\n**Acceptance.** Smoke green (each job runs once on staging); `curl -sf /api/admin/claude-usage` shows rotated accounts; Bian's Mac launchd jobs disabled per D31's window.",
    "labels": [
      "lane:A",
      "size:L",
      "wave:S3-W2"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A10",
      "D31"
    ],
    "links": []
  },
  {
    "key": "A12a",
    "title": "Teardown native jobs + artefact pages",
    "description": "## A12a \u00b7 Teardown native jobs + artefact pages \u00b7 A \u00b7 M \u00b7 S2-W5\n**Read first.** `lib/teardown/{worker,prompts}.cjs`, `ad_teardowns`, `enqueue_brand_teardowns`, `/breakdowns`.\n**Deliverables.** Teardown per family + brand fan-out as Skynet jobs (same worker over `lib/db.cjs`, cost cap + model from settings); the breakdown prompt becomes a versioned prompt asset (`core.prompt_assets`, Mathew-editable \u2014 the first use of the asset model V14 completes in S3); API `/api/intelligence/teardowns*`; artefact view data for V15; `pytest apps/api/tests/intelligence/test_teardown_jobs.py`.\n**Acceptance.** Test green; one brand fan-out drains on staging; the prompt asset version is recorded on each artefact.\n**Bridge impact.** The Grok `lfs_extract` prompt + schema from bridge I1 is the seed for the teardown prompt asset.\n**Out of scope.** The two actions (A12b).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W5"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A9",
      "A13a"
    ],
    "links": []
  },
  {
    "key": "A12b",
    "title": "Create script / Recreate actions + brief stub / Expand hand-off",
    "description": "## A12b \u00b7 Create script / Recreate actions + brief stub / Expand hand-off \u00b7 A \u00b7 M \u00b7 S2-W6\n**Read first.** A12a; functional design user stories 1\u20132; the S1 Expand entry in `apps/api/production`; `studio.briefs` stub (until S3).\n**Deliverables.** **Create script from this** \u2192 a script brief entity (`studio.briefs`: teardown ref, evidence packet, product, status) with lineage `teardown \u2192 brief`; **Recreate** \u2192 a Production Expand session seeded with the ad's media, lineage `ad \u2192 session`; **Add to brief** \u2192 append the teardown/VOC evidence packet to an existing brief; **Tag for teammate** \u2192 an inbox item with the selected evidence packet and assignee; when a teardown or research artefact becomes a brief, auto-attach the matching VOC packet (top phrases/threads for the product and selected group, with source metadata and date window), with add/remove controls preserved in the brief payload; `pytest apps/api/tests/intelligence/test_teardown_actions.py`.\n**Acceptance.** Test green; one teardown \u2192 one brief + one Expand session on staging (ids attached).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W6"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A12a"
    ],
    "links": []
  },
  {
    "key": "A13a",
    "title": "Competitors, lenses, schedules \u2014 model + migration",
    "description": "## A13a \u00b7 Competitors, lenses, schedules \u2014 model + migration \u00b7 A \u00b7 M \u00b7 S2-W3\n**Context.** The functional design's model: competitors are workspace entities linked to products, each link carrying market/locale settings; lenses = Ads-Library page, keyword query, domain; schedules per link.\n**Read first.** Functional design \u00a73.0, \u00a73.1; `brands` table + `is_research_lens`; `admin/components/{AddBrandForm,BrandScheduleCard}.tsx`; scheduler cron (`server.js:1516`); D21 rules.\n**Deliverables.** Migration `meta_ads/002_competitors.sql`: `competitors(id, workspace_id, name, domain, notes)`, `competitor_lenses(competitor_id, kind \u2208 page|keyword|domain, value, country)`, `product_competitors(product_id, competitor_id, market, locale, auto_recreate_rule jsonb, alert_rule jsonb)`; data migration `brands` \u2192 competitors + lenses (1:1, a compatibility view for the engine); the scheduler reads `product_competitors` (interval per link); `pytest apps/api/tests/intelligence/test_competitor_product_mapping.py`.\n**Acceptance.** Test green with one product \u2194 three competitors, distinct lenses/schedules; migration verify (brand count = lens count).\n**Bridge impact.** The lander/destination filter proven in bridge I1/I2 becomes lens semantics here (two landers per brand).\n**Out of scope.** Alerts, auto-recreate, APIs (A13b).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W3"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A9",
      "D21"
    ],
    "links": []
  },
  {
    "key": "A13b",
    "title": "Traction alerts + auto-recreate rules + competitor APIs",
    "description": "## A13b \u00b7 Traction alerts + auto-recreate rules + competitor APIs \u00b7 A \u00b7 M \u00b7 S2-W4\n**Read first.** A13a model; D21 rules; static-ads `competitor_brand_mappings`, `auto_expand_runner.py`; Discord/Telegram senders (X3).\n**Deliverables.** Alert job: traction rule per link (new family \u2265 N days running / rank rise) \u2192 Discord/Telegram + an outbox event (inbox in S3); auto-recreate rule \u2192 Production Expand session via the S1 Expand entry; API `/api/intelligence/competitors*` (CRUD, link/unlink, rules); archive/merge suggestions on competitor links (\"silent for N days \u2192 archive?\", \"same family / same library entity as an existing competitor \u2192 merge?\") as explicit suggestion records, never auto-applied; alert rules create inbox/event artefacts only, never direct actions; `pytest apps/api/tests/intelligence/test_competitor_rules.py`.\n**Acceptance.** Test green; one alert fired and one Expand session created on the fixture.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W4"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A13a",
      "D21"
    ],
    "links": []
  },
  {
    "key": "A14",
    "title": "Research native backend + ranked opportunities",
    "description": "## A14 \u00b7 Research native backend + ranked opportunities \u00b7 A \u00b7 M \u00b7 S2-W4\n**Read first.** `lib/research/{runner,cluster,score}.cjs`, `research_*` tables, `research_products_ranked` view, `admin/app/research/*`, `tools/seed-research-keywords.cjs`; D21.\n**Deliverables.** Research runs as Skynet jobs (same engine code over `lib/db.cjs`), keywords/niches per workspace, suggested keywords from tracked-competitor terms and the VOC corpus into a review queue (nothing auto-seeds), runs resumable at boot, ranked products with reasons and gaps, decision tracking on each opportunity (`status`, `reason_code`, `decision_note`, `decided_by`, `decided_at`, `cooldown_until` \u2014 default 30 days, hidden from the active view until new evidence); API `/api/intelligence/research/*`; `pytest apps/api/tests/intelligence/test_research_native.py`.\n**Acceptance.** Test green; a staging run over a known keyword set produces the ranked list with reasons.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W4"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A9",
      "D21"
    ],
    "links": []
  },
  {
    "key": "A15",
    "title": "Promote to product (+ ClickUp Product card via bridge)",
    "description": "## A15 \u00b7 Promote to product \u00b7 A \u00b7 M \u00b7 S2-W5\n**Read first.** D22; static-ads product model (`routes/products.py:43,151,248,282`); workos `LISTS.products`, `Product ID`; S8a bridge module (extend with `POST /products` create-or-link).\n**Deliverables.** Action from a ranked opportunity, gated: the researcher recommends, a strategist approves (Mathew for pilot products); on approval create the Skynet product (name, market, brand identity skeleton, docs/looks/offer placeholders flagged `incomplete`, plus the accelerator's client-file fields: *proof we are allowed to use*, *what died / dead angles*, *current winners*), link the competitors and intel from the run, create or link the ClickUp Product card through the bridge (Product ID = naming product number from `rescale_service.next_product_number`), write lineage `research \u2192 product`, and create a product readiness checklist (researcher: niche/competitors/evidence; strategist: positioning/claims/brand truth; ops: workflow fields) that must be complete before the product can enter script generation; `pytest apps/api/tests/intelligence/test_promote_to_product.py`.\n**Acceptance.** Test green; one recorded staging promotion (product id, linked competitors, ClickUp card id).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W5"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A14",
      "D22"
    ],
    "links": []
  },
  {
    "key": "A17",
    "title": "Hardening: load tests, abuse cases, security-review remediation",
    "description": "## A17 \u00b7 Hardening: load tests, abuse cases, security-review remediation \u00b7 A \u00b7 L \u00b7 S5-W4\n**Deliverables.** Load test of the full stack under S5 quotas (`infra/smoke/load.sh`); abuse cases (cross-workspace ids in every mutating route, token replay, oversized uploads, runaway jobs); dependency + image scanning in CI; secrets audit; findings \u2192 fixes; `docs/security/S5-security-review.md` (findings, status, evidence).\n**Acceptance.** Review doc merged with every finding closed or explicitly accepted by Agon; load summary attached.",
    "labels": [
      "lane:A",
      "size:L",
      "wave:S5-W4"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "D53",
      "S21",
      "S22",
      "S23",
      "S24"
    ],
    "links": []
  },
  {
    "key": "A18",
    "title": "Reddit VOC pipeline (groups, continuous, retained, export)",
    "description": "## A18 \u00b7 Reddit VOC pipeline \u00b7 A \u00b7 M \u00b7 S2-W6\n**Read first.** D23; `lib/market-intel/{reddit-client,ingest-worker}.cjs`, `mi_scopes`, `mi_documents`, `mi_document_scopes`, crons `5,20,35,50 * * * *` + retention `40 3 * * *`.\n**Deliverables.** `mi_keyword_groups(workspace_id, name, keywords[], scopes[])` (first: cravings, blood sugar); continuous collection per group (scope runs tagged with the group); retention: never purge documents that belong to a group; export `GET /api/intelligence/voc/export?group&from&to` \u2192 CSV/JSON (columns per D23); UI in V15.\n**Acceptance.** `node services/scraper/scripts/run-reddit-voc-smoke.mjs --group cravings` collects and the export returns rows; sample attached.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W6"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A9",
      "D23"
    ],
    "links": []
  },
  {
    "key": "A18b",
    "title": "PubMed evidence adapter + six-lens scopes + MCP + export",
    "description": "## A18b \u00b7 PubMed evidence adapter + six-lens scopes + MCP + export \u00b7 A \u00b7 M \u00b7 S2-W7\n**Context.** Reddit tells the team how the customer talks about the problem; PubMed tells them why it happens. Six lenses (mechanism, root cause, symptoms, ingredients, failed solutions, desired outcomes) are searched per keyword group and the findings are classified by evidence strength, so briefs and scripts can carry fact-based hooks, authority statements, belief shifts, symptoms to call out, and reasons other solutions fail \u2014 in plain customer language, with a citation on every row.\n**Read first.** D23b; `development-processes/pubmed-evidence-layer-plan.md` (\u00a72 lenses + classifier, \u00a73 schema, \u00a74 MCP); `lib/market-intel/{reddit-client,ingest-worker,amazon-worker}.cjs`, `mi_scopes` (`product_name`), `mi_documents`, `mi_document_scopes`; NCBI E-utilities (`esearch` / `efetch` / `elink`, `datetype=edat`, `tool` + `email` required, `NCBI_API_KEY` for 10 req/s).\n**Deliverables.** `lib/market-intel/{pubmed-client,normalize-pubmed,pubmed-classifier,pubmed-worker,pubmed-corpus}.cjs` (plain `fetch` + XML, retry/backoff, rate limit); migration `add_market_intel_pubmed.sql` \u2014 `mi_scopes.source` gains `pubmed`, `scope_type` gains `pubmed_query`, `mi_scopes.group_key` (FK to A18's `mi_keyword_groups`) + index, `params = {lens, template_version, terms}`; `mi_documents.doc_type` gains `article`, new `citation TEXT` and `evidence JSONB` (`doi, journal, pub_types[], mesh[], humans, animals, study_design[], sample_n, sample_n_confidence, evidence_class, pmc_id, lens[]`) + index on `evidence_class`; `body` = abstract or title fallback (`metrics.has_abstract=false`), `author_hash` null (public authorship), `purge_after` never set; classifier = publication types + MeSH Humans/Animals + study-design cues + sample-size extractor \u2192 classes `strong / moderate / preclinical / case-report / narrative-review / traditional-use / unclassified`; worker watermark on the Entrez indexing date with a 7-day overlap + monthly full re-walk per scope, `watermark_gap` never set; six lens scopes per keyword group seeded from D23b; thin MCP adapter `lib/mcp/pubmed-mcp.cjs` mounted at `/mcp/pubmed` (Streamable HTTP, miAuth token; tools `pubmed_search`, `pubmed_get`, `pubmed_evidence_corpus`, `pubmed_lenses`); `/market-intel/pubmed/{run,stats}`; admin page `admin/app/market-intel/pubmed`; documents route + service types gain the article fields; export `GET /api/intelligence/evidence/export?group&lens&class&from&to` \u2192 CSV/JSON (columns per D23b); `test/market-intel-pubmed.test.mjs` with recorded fixtures (a no-abstract record, an animal study, a meta-analysis), the classifier table, sample-size cases, edat overlap, backoff.\n**Acceptance.** Test green; `node services/scraper/scripts/run-pubmed-evidence-smoke.mjs --group cravings` collects all six lenses and the export returns classified rows; an MCP client lists the four tools and `pubmed_search` returns ranked rows on staging; sample attached.\n**Bridge impact.** If R7a is built in the standalone, this card is a port of the proven adapter + MCP into the unified market-intel spine, not a build.\n**Out of scope.** The evidence packet in briefs (A18c); the `/evidence-layer` skill (RescaleOS now, Studio via A11).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W7"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A9",
      "A18",
      "D23b"
    ],
    "links": []
  },
  {
    "key": "A18c",
    "title": "Evidence packet in briefs",
    "description": "## A18c \u00b7 Evidence packet in briefs \u00b7 A \u00b7 S \u00b7 S2-W8\n**Read first.** A12b (VOC packet auto-attach), A18b (`pubmed_evidence_corpus`), `studio.briefs` stub.\n**Deliverables.** When a teardown or research artefact becomes a brief, the evidence packet (top findings per lens for the product and group: plain-language finding, citation, evidence class, PMID link) is auto-attached next to the VOC packet, with add/remove controls preserved in the brief payload; the packet carries the claims-policy flags (`form-mismatch`, class) so script generation and the compliance pass read them from the row, never from the prose; `pytest apps/api/tests/intelligence/test_evidence_packet.py`.\n**Acceptance.** Test green; one staging brief shows both packets (ids attached).\n\n---",
    "labels": [
      "lane:A",
      "size:S",
      "wave:S2-W8"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A12b",
      "A18b"
    ],
    "links": []
  },
  {
    "key": "A19",
    "title": "Scraper retirement + duplicate-scraper shutdown + archive",
    "description": "## A19 \u00b7 Scraper retirement + duplicate-scraper shutdown + archive \u00b7 A \u00b7 M \u00b7 S2-W8\n**Read first.** A2d (both stacks), `/opt/meta-ads` compose, static-ads `scraped_ads.py`, `decodo_client.py`, `scrape_runner.py`, `ad_collections`, bridge cards I1/I2/R7a.\n**Deliverables.** Cutover day: final `meta_ads` delta migration (verify script), old crons stopped, `meta-ads.rescale.media` \u2192 redirect to `app.rescale.media/intelligence`, old Supabase project read-only then archived (dump to the Storage Box), old container removed from `/opt/meta-ads`; static-ads' scraper routes removed from `apps/api`, collections migrated to tags; bridge export surfaces retired (`/bridge/lfs/runs*`, the package route, the Primal Queen continuous folder-drop path, any bridge-only schedule/worker, `is_long_copy_native`, the `lfs_extract` tier after A12a absorbs it); `docs/runbooks/S2-intelligence-cutover.md`.\n**Acceptance.** Checklist: old cron off, redirect live, `rg \"bridge/lfs|lfs_extract|BRIDGE_DROP_ROOT\" services/scraper` = 0, `rg decodo apps/api` = 0, archive dump present; X3 checks updated.\n**Bridge impact.** Retires every scraper-side bridge path proved in I1/I2/R7a.\n**Out of scope.** The console (A20).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W8"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A2d",
      "V15"
    ],
    "links": []
  },
  {
    "key": "A1a",
    "title": "Repo + imports + CODEOWNERS",
    "description": "## A1a \u00b7 Repo + imports + CODEOWNERS \u00b7 M \u00b7 `chore/a/a1a-monorepo`\n**Context.** One repo with history from four. Runtime bumps ride along so day-1 CI runs on the target versions.\n**Read first.** Technical plan \u00a72.1 (layout); S0a hand-over (scrubbed workos tree path + pattern file); source layouts above; scraper `Dockerfile` (Node 20 \u2192 24: Playwright base image stays `v1.58.1-noble`, deps stages become `node:24-bookworm-slim`), static-ads `Dockerfile` (`python:3.12-slim` \u2192 `3.13-slim`; frontend stage `node:24-alpine`).\n**Deliverables.** (1) `Rescale-Media/skynet` created; `git subtree add --prefix=<dest> <repo> main` for: static-ads-automation \u2192 `apps/api` (paths `app/backend`, `app/pipeline`, `schema.sql`, `requirements.txt`, `Dockerfile`, `supervisord.conf`, `docker/`, `scripts/`, `tools/`, `flowkit/`) and `apps/web` (`app/frontend`) \u2014 one subtree into `apps/static-ads-import/` then `git mv` into the two targets in the same PR (history preserved by `git log --follow`); meta-ads-scraper \u2192 `services/scraper`; its `transcriber/` \u2192 `services/transcriber` (`git mv`); RescaleOS \u2192 `services/agent-runtime` (`console/` + `workspaces/`, engine dirs); the scrubbed workos tree \u2192 `services/workos` (plain copy, not subtree \u2014 the old repo's history contains secrets). (2) Root `CLAUDE.md` + `AGENTS.md` (package map + the TechDesk task-discipline rules from `techdesk-skynet-integration-plan.md` \u00a71.4), `CODEOWNERS` (`apps/api/production/* @vince`, `apps/web/* @vince`, `apps/api/core/* @sead`, `packages/contracts/* @sead`, `services/workos/* @sead`, `services/scraper/* services/transcriber/* services/agent-runtime/* infra/*.github/* @agon`, `db/migrations/* @sead @agon`), `.gitignore` union + `secrets/`, `.pre-commit-config.yaml` from S0a, Renovate config. (3) Runtime bumps in the same PR: scraper Dockerfile deps stages `node:24-bookworm-slim` (rebuild `sharp`, run `node --test test/`), `apps/api/Dockerfile` `python:3.13-slim` (`pytest app/backend/tests`), frontend stage `node:24-alpine` (`npm run build`), console `node:24-bookworm-slim` (`npm run build`). (4) Old repos: `README.md` pointer + archived (GitHub \"archive\" flag) except rescale-workos (stays live, private, per S0a) and meta-ads-scraper/RescaleOS (stay live until their image ownership flips in a later slice \u2014 add the pointer only).\n**Acceptance.** In the monorepo: `pytest apps/api/app/backend/tests` green on 3.13; `cd apps/web && npm run build` green on Node 24; `cd services/scraper && node --test test/` green on Node 24; `cd services/agent-runtime/console && npm run build` green; `cd services/workos/service && pnpm typecheck && pnpm test` green; `gitleaks detect` on the repo = 0; `git log --follow apps/api/app/backend/main.py | wc -l` > 1 (history kept). Paste all seven command outputs.\n**Out of scope.** Any behaviour change; CI workflows (A1b).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:W1"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "S0a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "chore/a/a1a-monorepo"
      }
    ]
  },
  {
    "key": "A1b",
    "title": "CI + governance",
    "description": "## A1b \u00b7 CI + governance \u00b7 M \u00b7 `chore/a/a1b-ci`\n**Read first.** Implementation PRD \u00a75; existing workflows (`ecomprofits/.github/workflows/docker-build.yml` for the SSH/compose pattern, `RescaleOS/.github/workflows/docker-build.yml` for build/notify); A1a layout.\n**Deliverables.** `.github/workflows/ci.yml` (PR + main; `dorny/paths-filter` \u2192 jobs `contracts` (`pnpm --filter contracts test` + `pytest packages/contracts`), `api` (`pytest apps/api/app/backend/tests`), `web` (`npm run lint && npm run build`), `scraper` (`node --test test/`), `runtime` (`npm run build`), `workos` (`pnpm typecheck && pnpm lint && pnpm test`), `secrets` (gitleaks with `infra/secret-scan/gitleaks.toml`, always)); `.github/workflows/deploy.yml` skeleton (build matrix \u2192 GHCR for `skynet-api`, `skynet-web` only; `deploy-staging`, `smoke-staging`, `deploy-prod` jobs stubbed with `if: vars.DEPLOY_STAGING_ENABLED == 'true'` etc.; `notify` Telegram as RescaleOS); branch protection on `main` via `gh api` script `infra/github/protect-main.sh` (required checks = the seven job names, 1 review, linear history, no force push); PR template with `Task: <key>` / `ADR: <n|none>`; `docs/CI.md`.\n**Acceptance.** A PR touching only `apps/web` runs `web` + `secrets` + `contracts` only (screenshot of the checks list); a PR without green checks cannot merge (screenshot of the blocked merge button); `gh api repos/Rescale-Media/skynet/branches/main/protection` output pasted.\n**Out of scope.** Real deploys (A6/X7).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:W1"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "chore/a/a1b-ci"
      }
    ]
  },
  {
    "key": "A2",
    "title": "Compose stack + local dev",
    "description": "## A2 \u00b7 Compose stack + local dev \u00b7 M \u00b7 `chore/a/a2-compose`\n**Read first.** Implementation PRD \u00a74 (services table, ports, staging isolation) and \u00a75; V3 (`migrate` entrypoint); sidecar compose facts above.\n**Deliverables.** `infra/compose/docker-compose.dev.yml` (project `skynet-dev`: `postgres:17` with `pg_data` volume + init SQL creating schemas `core`, `production`; `migrate` (`apps/api` image, `python apps/api/migrate.py`); `api` (uvicorn 8000, env from `infra/env/dev.env.example`); `caddy` (`infra/caddy/Caddyfile.dev`, port 38000) with exactly these dev routes: `handle /api/*` \u2192 `api:8000`; `handle` \u2192 static `apps/web/dist` with SPA fallback; unified app only, no mounted legacy UIs or sidecars); `infra/compose/compose.prod.yml` (project `skynet`: `caddy`, `api`, `postgres`, `migrate` [profile `ops`], `backup`, `synthetic`; internal network only; images by digest from `.env.release`) and `compose.staging.yml` (project `skynet-staging`, port 38001, own network and volumes, unified app only); `Makefile`: `make dev` (up + migrate + seed workspace/admin via `apps/api/core/scripts/seed_dev.py`), `make dev-down`, `make logs`; `docs/runbooks/LOCAL_DEV.md`.\n**Acceptance.** Fresh clone: `make dev` \u2192 `docker compose -p skynet-dev ps` shows every service `healthy`; `curl -s localhost:38000/api/healthz` \u2192 `{\"ok\":true}`; `curl -I localhost:38000/production` returns `200`; runbook merged. Paste `ps` output.\n**Out of scope.** Real Caddy SSO rules (A3), staging deploy (A6).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:W2"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A1a",
      "A1b",
      "V3"
    ],
    "links": [
      {
        "label": "branch",
        "url": "chore/a/a2-compose"
      }
    ]
  },
  {
    "key": "A20",
    "title": "Standalone console retirement + archive",
    "description": "## A20 \u00b7 Standalone console retirement + archive \u00b7 A \u00b7 M \u00b7 S3-W9\n**Deliverables.** Per D31: Mac launchd jobs off, console URL \u2192 redirect to `/studio`, SQLite archived (WAL-safe copy to the Storage Box), `/opt/rescaleos` stack removed, bridge-only skills and transport notes retired (`/lfs`, `/evidence-layer` bridge transport wording where the Studio-native flow replaces it, R7 MCP wiring), `docs/runbooks/S3-studio-cutover.md`; comms to Bian.\n**Acceptance.** Checklist: authoring jobs run only on the stack (job log), old URL redirected, archive present, `rg \"/lfs|evidence-layer\" services/agent-runtime apps/web/src/studio docs` shows only native/live references.\n**Bridge impact.** Retires every console-side bridge path proved in MC1/I3/R7.\n**Out of scope.** Anything the Studio-native flows have not yet replaced (stays until they do).\n\n\n---",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S3-W9"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A24",
      "D31",
      "V20",
      "V23"
    ],
    "links": []
  },
  {
    "key": "A21",
    "title": "Studio parity APIs (scripts, revisions, sessions, gates, scoreboard, opportunities, backlog, notifications, playbook, research ingest, KPI reminder, feedback pollers)",
    "description": "## A21 \u00b7 Studio parity APIs \u00b7 A \u00b7 L \u00b7 S3-W3\n**Deliverables.** Every console-facing API the native pages need, on the Postgres DAL: scripts (list/detail/revisions/archive), sessions (start/stream/gates/artifacts), scoreboard/weekly results, opportunities + votes, backlog (digests/ideas/accept), editors/assignment (`/api/assign` semantics; the RescaleOS ad-name format retired in favour of the creative code + v3 name from S3/S8a), notifications, playbook, research ingest, KPI reminder (now a readback check), feedback pollers (Telegram/Discord) state; route map old \u2192 new in `docs/checklists/studio-api-parity.md`.\n**Acceptance.** `pnpm --filter agent-runtime test -- studio-parity` green; the parity map complete.",
    "labels": [
      "lane:A",
      "wave:S3-W3",
      "size:L"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A10",
      "A11"
    ],
    "links": []
  },
  {
    "key": "A22",
    "title": "Sidecar + runtime quota enforcement (scraper, transcriber, runtime, workos, production callers)",
    "description": "## A22 \u00b7 Sidecar + runtime quota enforcement \u00b7 A \u00b7 M \u00b7 S5-W2\n**Read first.** S22 resolver; scraper `settings` reads, transcriber, agent runtime `accounts.ts`, workos config, production `gateway.py` (V8).\n**Deliverables.** Every service resolves workspace-scoped credentials and checks quotas before work: scraper crons per workspace, transcriber batches, runtime sessions, workos bridge calls, production model calls; pause \u2192 402/skip with a logged reason; `pnpm --filter scraper test && pnpm --filter agent-runtime test` extended; a staged quota-breach transcript for two services.\n**Acceptance.** Transcript attached; no service reads a global key when a workspace key exists.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S5-W2"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "S22"
    ],
    "links": []
  },
  {
    "key": "A23",
    "title": "Chat source adapters: Telegram + Discord channel ingestion",
    "description": "## A23 \u00b7 Chat source adapters: Telegram + Discord channel ingestion \u00b7 A \u00b7 M \u00b7 S2-W7\n**Context.** Selected Telegram/Discord channels become sources in the market-intel corpus (customer/community channels) and later feed the feedback lanes (team channels, S3). Consent per channel; authors salted-hashed like every other market-intel source.\n**Read first.** `lib/market-intel/{ingest-worker,reddit-client}.cjs`, `mi_scopes` (`scope_type`), `mi_documents`, `MARKET_INTEL_AUTHOR_SALT`; RescaleOS `console/src/server/cron/feedbackPoller.ts`, `console/src/server/feedbackPollers/{telegram,discord,secrets}.ts` (bot polling, offsets, attachment capture); Discord bot install pattern in ecomprofits `notification-platform/src/server/discord-install.service.ts`.\n**Deliverables.** `mi_scopes.scope_type` gains `telegram_channel` and `discord_channel` (bot token per workspace from `core.integration_credentials`, channel id, consent flag, keyword group); a poller worker (long-poll Telegram `getUpdates` with offsets; Discord gateway or channel REST with `after`) writing `mi_documents` (+ thread reconstruction like Reddit); retention per D51; searchable in the VOC UI (V15) by source `telegram|discord`; `node --test services/scraper/test/chat-adapters.test.js`.\n**Acceptance.** Test green with recorded fixtures; one consented channel ingests on staging and appears in the VOC search with the author hashed.\n**Out of scope.** Team-feedback lanes and the agent (S3, A24); DMs; channels without consent.\n",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W7"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A18",
      "A9"
    ],
    "links": []
  },
  {
    "key": "A24",
    "title": "Agents in chat: bot proposals with quick actions, @skynet tag-and-ask",
    "description": "## A24 \u00b7 Agents in chat: bot proposals with quick actions, @skynet tag-and-ask \u00b7 A \u00b7 L \u00b7 S3-W8\n**Context.** The bot tags humans with proposals when work finishes (\"scrape done \u2014 3 new families, 1 alerted; teardown / add to brief / ignore\"), and the team can tag the bot in a thread to ask or act.\n**Read first.** S27 renderers + quick-action contract; RescaleOS `managedSession.ts` (session with context), `feedbackPollers/*`; S14 inbox; the permission catalogue (S1a).\n**Deliverables.** Quick-action handlers (Discord interaction endpoint, Telegram callback queries) \u2192 Skynet actions (teardown, add to brief, promote, confirm winner, assign) with the actor resolved to a Skynet user (chat identity \u2194 user mapping, `core.chat_identities`) and permission-checked; `@skynet` / bot mention in a thread \u2192 an agent session with the thread + detected entities (creative code, product, ad link) as context, answers in-thread, may act through the same handlers; transcript + outcome attached to the inbox item; rate limits and a kill switch; `pytest apps/api/tests/core/test_chat_agent.py` (mocked chat APIs).\n**Acceptance.** Test green; staging demo: a finished research run posts a proposal with buttons, a human presses \"promote\" and the product is created with lineage; an `@skynet` question about a creative code answers with its hook rate.\n**Out of scope.** Voice; DMs; channels not registered as destinations.\n",
    "labels": [
      "lane:A",
      "size:L",
      "wave:S3-W8"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "S27",
      "S14",
      "A11"
    ],
    "links": []
  },
  {
    "key": "A2c",
    "title": "Compose resource limits + Postgres tuning + concurrency caps",
    "description": "## A2c \u00b7 Compose resource limits + Postgres tuning + concurrency caps \u00b7 S \u00b7 W2 \u00b7 `chore/a/a2c-limits` (S1-rev)\n**Context.** Skynet is a bounded tenant on the production Supabase box: \u2264 12 cores / \u2264 32 GB in total, enforceable in host-side compose.\n**Read first.** Architecture doc \u00a79 (the table and thresholds \u2014 this card implements it exactly); Docker compose fields `cpus`, `mem_limit`, `mem_reservation`, `pids_limit`, `restart` (not `deploy.resources`).\n**Deliverables.** In `infra/compose/compose.prod.yml` (and the same values in `compose.staging.yml`): api `cpus: 6`, `mem_limit: 12g`, `mem_reservation: 8g`, `pids_limit: 512`; postgres `cpus: 4`, `mem_limit: 12g`, `mem_reservation: 8g`, `pids_limit: 256`, command flags `-c shared_buffers=3GB -c effective_cache_size=8GB -c work_mem=16MB -c maintenance_work_mem=512MB -c max_connections=80 -c max_wal_size=4GB`; caddy `cpus: 1`, `mem_limit: 1g`; backup and synthetic `cpus: 0.5`, `mem_limit: 512m`, backup wrapped in `nice -n 10 ionice -c2 -n7`; every service `restart: unless-stopped`. App env: `MAX_CONCURRENT_IMAGE_JOBS=4`, `MAX_CONCURRENT_STORYBOARD_JOBS=2`, `MAX_CONCURRENT_EXPORT_JOBS=4`, enforced by the job worker's semaphores (`apps/api/core/jobs/limits.py`) \u2014 no unbounded thread fan-out remains in `pipeline_runner.py` / `storyboard_queue.py`. `docs/ops/RESOURCE_LIMITS.md` = the table + how to change it.\n**Acceptance.** `docker compose -f infra/compose/compose.prod.yml config` shows the limits; on the dev stack `docker stats` shows the caps; `pytest apps/api/tests/core/test_job_limits.py` (a 10-job burst never exceeds 4 concurrent image jobs); `SHOW shared_buffers` on the stack's Postgres = 3GB.\n**Out of scope.** Limits for scraper/runtime services (S2 re-issue).",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W2"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A2"
    ],
    "links": []
  },
  {
    "key": "A2d",
    "title": "Scraper engine + transcriber as stack services with hard limits",
    "description": "## A2d \u00b7 Scraper engine + transcriber as stack services with hard limits \u00b7 A \u00b7 M \u00b7 S2-W3\n**Read first.** Architecture \u00a75, \u00a79 (re-issued for S2 in ADR-003); `ecosystem.config.cjs`, `Dockerfile`, `transcriber/Dockerfile`; `/opt/meta-ads/docker-compose.yml` on the rescale box (residential proxy settings, `TRANSCRIBER_URL`).\n**Deliverables.** `compose.prod.yml` (+ staging) gains `scraper-engine` (server.js only \u2014 no Next admin; `cpus: 6`, `mem_limit: 16g`, `pids_limit: 1024`, `shm_size: 1g`) and `transcriber` (`cpus: 2`, `mem_limit: 8g`, models volume); crons run inside the engine as today; healthchecks; residential proxy + Decodo settings via `.env.production`; A2c's limits table re-issued with these rows; `docs/ops/RESOURCE_LIMITS.md` updated; the old `/opt/meta-ads` stack keeps running until A19 (both write to different DBs \u2014 no double-writes: the old one keeps Supabase, the new one Skynet Postgres, and only the new one is scheduled after cutover day).\n**Acceptance.** `docker compose -p skynet ps` shows both healthy; `curl -sf localhost:38000/api/intelligence/healthz`; X3b-style pressure sample within limits.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:S2-W3"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A9"
    ],
    "links": []
  },
  {
    "key": "A3",
    "title": "Caddy for the unified app",
    "description": "## A3 \u00b7 Caddy for the unified app \u00b7 S \u00b7 W2 \u00b7 `feat/a/a3-caddy` (S1-rev)\n**Context.** One gateway container for the unified app only. No `/scraper`, `/studio`, no aliases, no cross-host proxying \u2014 the standalones are links.\n**Read first.** Architecture doc \u00a75 (caddy row), \u00a78 (cookies, CSRF), lock doc \u00a74.1; `apps/web` build output.\n**Deliverables.** `infra/caddy/Caddyfile` (env-parameterised): `:80`; `handle /api/*` \u2192 `api:8000` (`flush_interval -1`, `transport http { read_timeout 1h }` for SSE); `handle` \u2192 `apps/web/dist` with SPA fallback; `request_body { max_size 512MB }`; `header X-Request-Id {http.request.uuid}` forwarded upstream; rate limit on `/api/auth/*` (10/min/IP, `caddy-ratelimit` module built in `infra/caddy/Dockerfile`); trust `X-Forwarded-Proto` from `127.0.0.1` only; cookies untouched. `skynet-web` image = Caddy + built SPA.\n**Acceptance.** `infra/caddy/test.sh` on the dev stack: `/` 200 (SPA), `/production/anything` 200 (fallback), `/api/healthz` 200, 11th `/api/auth/login` in a minute \u2192 429, `X-Request-Id` visible in the api log, 300 MB upload passes / 600 MB \u2192 413, `curl -N /api/products/x/sessions/y/progress/stream` streams. Paste the output.\n**Out of scope.** TLS (Plesk); anything for the standalone apps.",
    "labels": [
      "lane:A",
      "wave:W2",
      "size:S"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A2",
      "S1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/a/a3-proxy-sso"
      }
    ]
  },
  {
    "key": "A3b",
    "title": "Deploy pipeline on the ecomprofits pattern (two targets, digest pinning, smoke gate, Telegram)",
    "description": "## A3b \u00b7 Deploy pipeline on the ecomprofits pattern \u00b7 M \u00b7 W3 \u00b7 `chore/a/a3b-deploy` (S1-rev)\n**Context.** Push to `main` \u2192 build \u2192 deploy staging (`server.rescale.media`) \u2192 smoke gate \u2192 deploy prod (`server.ecomprofits.io`) \u2192 Telegram. Same pattern ecomprofits uses on the production box, with digest pinning and a whole-file env secret.\n**Read first.** Architecture doc \u00a77 (the exact steps, guarantees, rollback); `ecomprofits/.github/workflows/docker-build.yml` (SSH via `appleboy/ssh-action`, compose pull/up, Telegram curl format); RescaleOS `DEPLOY.md` \u00a72b (env-from-secret validation, `.bak-*`); A1b's `deploy.yml` skeleton.\n**Deliverables.** `.github/workflows/deploy.yml`: `build` (GHCR `skynet-api`, `skynet-web`, tags `main-<sha>` + `latest`, outputs `API_DIGEST`, `WEB_DIGEST`, `GIT_SHA` build arg) \u2192 `deploy-staging` (SSH; `git fetch && git reset --hard origin/main`; decode `SKYNET_STAGING_ENV_B64`, validate (non-empty, required keys list in `infra/deploy/required-keys.txt`, no `ANTHROPIC_API_KEY=`), `.env.staging.bak-<ts>`, atomic write; write `.env.release` with `IMAGE_TAG`, `API_IMAGE_DIGEST`, `WEB_IMAGE_DIGEST`; `docker compose run --rm migrate`; `pull`; `up -d`; wait `/api/healthz` 60 s) \u2192 `smoke-staging` (`infra/smoke/run.sh`, architecture \u00a77 step 3) \u2192 `deploy-prod` (same steps on `/opt/skynet` with `SKYNET_ENV_B64`, the **same digests**, gated by `vars.DEPLOY_PROD_ENABLED`; pre-migrate `pg_dump -Fc` to `/var/lib/skynet-backups/pre-deploy/`; abort if `df --output=avail /` < 20 GB) \u2192 `notify` (Telegram, ecomprofits format). Compose files reference `${API_IMAGE_DIGEST}` / `${WEB_IMAGE_DIGEST}` (`image: ghcr.io/rescale-media/skynet-api@${API_IMAGE_DIGEST}`), never `latest`. `infra/deploy/rollback.sh <sha>` per architecture \u00a77. `docs/runbooks/DEPLOY.md`.\n**Acceptance.** A push to `main` runs green through `smoke-staging` with `deploy-prod` skipped (flag off) \u2014 run link; `.env.release` on staging shows the digests from that run's build job; `rollback.sh <previous-sha>` on staging brings back the previous `/api/version` sha in under 15 min (transcript).\n**Out of scope.** Production enablement (X7).",
    "labels": [
      "lane:A",
      "size:M",
      "wave:W3"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A1b",
      "A2",
      "A3"
    ],
    "links": []
  },
  {
    "key": "A4",
    "title": "Intelligence data-path proof + pressure test \u2192 ADR-003",
    "description": "## A4 \u00b7 Intelligence data-path proof + pressure test \u2192 ADR-003 \u00b7 A \u00b7 L \u00b7 S2-W1\n**Context.** Decided: real port to direct Postgres, no PostgREST. A4 proves it before A9 commits \u2014 schema, RPC semantics, load on the shared box.\n**Read first.** `migrations/full-schema.sql`, `supabase-schema.sql`, every `migrations/*.sql` `create function meta_ads.*`; `server.js` client creation and every `.rpc(`/`.from(`/`storage.from(` site (`rg` inventory, one row each); architecture \u00a79; D21\u2013D23.\n**Deliverables.** (1) Restore a fresh scraper dump into a scratch Postgres 17 as schema `meta_ads` (roles `anon/authenticated/service_role` dropped; one app role); run every RPC by hand with fixture args and record result parity vs Supabase (`docs/spikes/a4-parity.csv`: rpc/table/storage/realtime row, result). (2) Prototype the `pg` client layer (`services/scraper/lib/db.cjs`: pool, `rpc(name, args)` \u2192 `select * from meta_ads.<fn>(...)`, `from(table)` helpers used by the code today) on three representative paths (browse, enqueue teardown, research ranking). (3) Pressure test on the production box with the engine + transcriber under proposed caps (8 cores / 24 GB) while sampling Supabase latency (X3b method). (4) `docs/decisions/ADR-003-intelligence-data-path.md`: direct `pg` is the path; limits table for A2d; the migration runbook draft.\n**Acceptance.** Parity CSV with every row PASS or explained; pressure report shows Supabase p95 within 10 % of baseline; ADR merged.\n**Out of scope.** The cutover (A9).",
    "labels": [
      "lane:A",
      "size:L",
      "wave:S2-W1"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "D21",
      "D22",
      "D23"
    ],
    "links": [
      {
        "label": "branch",
        "url": "spike/a/a4-postgrest"
      }
    ]
  },
  {
    "key": "A6",
    "title": "Staging stack on server.rescale.media",
    "description": "## A6 \u00b7 Staging stack on `server.rescale.media` \u00b7 M \u00b7 W3 \u00b7 `chore/a/a6-staging` (S1-rev)\n**Context.** Staging is the unified app only, on the rescale box, proving migrations, the read layer, the loop and the deploy itself.\n**Read first.** Architecture doc \u00a76; A2 `compose.staging.yml`; A3b.\n**Deliverables.** `/opt/skynet-staging` running project `skynet-staging` on `127.0.0.1:38001`, network `skynet-staging-net`, own volumes; `.env.staging` from `SKYNET_STAGING_ENV_B64` (read-layer role `skynet_readonly`, R2 staging prefix, Resend/SMTP sandbox, `LEGACY_LOGIN_DISABLED=0`); `vars.DEPLOY_STAGING_ENABLED=true`; nightly `pg_dump -Fc` into `/var/www/vhosts/rescale.media/private/pgdump/skynet-staging-<date>.dump` (existing sweep); seeded smoke user.\n**Acceptance.** `https://staging.app.rescale.media/` loads with TLS; `docker compose -p skynet-staging ps` all healthy; the smoke gate passes on the next push (run link); the dump exists the next morning (`ls -l`).\n**Out of scope.** Production (X7); any standalone app.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:W3"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A1a",
      "A1b",
      "A2",
      "A3",
      "A3b",
      "V2a",
      "V3",
      "S1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "chore/a/a6-staging"
      }
    ]
  },
  {
    "key": "A9",
    "title": "`meta_ads` migration + scraper direct-`pg` cutover",
    "description": "## A9 \u00b7 `meta_ads` migration + scraper direct-`pg` cutover \u00b7 A \u00b7 L \u00b7 S2-W2\n**Read first.** A4 artefacts; `services/scraper/lib/db.cjs`; every call site from the A4 inventory; `admin/lib/supabase*.ts`; `admin/lib/auth.ts` (`admin_users`, HS256 cookie) \u2014 replaced by Skynet auth for the native pages.\n**Deliverables.** Migration `db/migrations/unified/meta_ads/001_baseline.sql` (schema + all functions, `workspace_id` added to every table with the default workspace, composite uniqueness updated); data migration runbook + script (`infra/migrate/meta_ads/{dump,restore,verify}.sh`, checksums per table); `server.js`, all `lib/*.cjs` workers and the remaining admin API routes rewired to `lib/db.cjs` (no `supabase-js`, no GoTrue, no Storage client \u2014 R2 only, no Realtime); env: `DATABASE_URL` (Skynet Postgres), `R2_*`; the scraper's `settings` table stays, read through the same client; `node --test test/` extended with a parity suite (old vs new answers for `ads_browse`, `intel_*`, `research_*`, teardown enqueue on the same fixture).\n**Acceptance.** Parity suite green; verify script: row counts + checksums equal after a full migration on staging; `rg \"supabase\" services/scraper --glob '!node_modules' --glob '!*.md'` = 0 runtime hits.\n**Out of scope.** Native UI (V22/V15); retiring the old stack (A19).",
    "labels": [
      "lane:A",
      "size:L",
      "wave:S2-W2"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A4"
    ],
    "links": []
  },
  {
    "key": "B0",
    "title": "Bridge sunset checklist",
    "labels": [
      "lane:A",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A19",
      "A20"
    ],
    "links": [],
    "description": "## B0 \u00b7 Bridge sunset checklist \u00b7 Agon \u00b7 S \u00b7 after A19 + A20 (Skynet card)\n**Deliverables.** `docs/checklists/bridge-sunset.md`: every bridge path (I1 endpoints, I2 drops, `/lfs`, `/evidence-layer` transport notes, R7 MCP) either ported or removed, owner named, folder drops replaced by native entities, docs no longer describe the bridge as active. **Acceptance.** Checklist signed by Agon and Mathew.",
    "wave": "B"
  },
  {
    "key": "D21",
    "title": "Research operator workflow confirmation (W1\u2013W3 rules)",
    "description": "## D21 \u00b7 Research operator workflow confirmation \u00b7 A \u00b7 S \u00b7 S2-W0\n**Context.** W1\u2013W3 in the workflow map are `mixed`: the tooling is code-backed, the operator rules are not. S2 builds the native Intelligence pages on those rules.\n**Deliverables.** `docs/discovery/s2-operator-rules.md`: what makes a competitor worth tracking; cadence per brand/lens; keep/discard rules for ads and families; when a finding becomes a brief; the evidence required before a product is promoted; who seeds research keywords; who approves Promote to product; the rework loop when a ranked opportunity is rejected or deferred; where the loop breaks today. Interview: Agon + the weekly research operator (45 min), plus one screen-share of a real weekly pass.\n**Acceptance.** Doc merged; Agon and the operator comment \"confirmed\" on the task; A13/A14/A15 cite the rules they implement.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:S2-W0"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D22",
    "title": "Product bridge field confirmation (Sven + Cyrus)",
    "description": "## D22 \u00b7 Product bridge field confirmation \u00b7 S \u00b7 S \u00b7 S2-W0\n**Deliverables.** `docs/discovery/s2-product-bridge-fields.md` with Sven + Cyrus: the exact ClickUp Product card fields (`LISTS.products`: Product ID = naming product number, market economics, status) that Promote to product must create/link, the mapping to the static-ads product model (`routes/products.py:43` product, `:151` brand identity, `:248` looks, `:282` documents), and who owns each field afterwards. Table: source \u00b7 target \u00b7 owner.\n**Acceptance.** Doc merged; Sven and Cyrus confirmed; A15 cites it.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S2-W0"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D23",
    "title": "Reddit VOC seed confirmation (Mathew)",
    "description": "## D23 \u00b7 Reddit VOC seed confirmation \u00b7 A \u00b7 S \u00b7 S2-W0\n**Deliverables.** `docs/discovery/s2-reddit-voc-seed.md` with Mathew: the first keyword groups (cravings, blood sugar \u2014 confirm wording and subreddits/search scopes), collection cadence, retention (never truncated per group), export columns (post/comment id, subreddit, date, score, text, group, keyword hit), who consumes the export.\n**Acceptance.** Doc merged with Mathew's confirmation; A18 cites it.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:S2-W0"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D23b",
    "title": "Evidence lens seed confirmation (Mathew)",
    "description": "## D23b \u00b7 Evidence lens seed confirmation \u00b7 A \u00b7 S \u00b7 S2-W0\n**Deliverables.** `docs/discovery/s2-evidence-lens-seed.md` with Mathew: for each VOC keyword group (first: cravings, blood sugar) the six-lens terms \u2014 mechanism, concern, ingredient(s), common solutions \u2014 plus the first RescaleOS product the evidence layer is proved on; collection cadence and the monthly re-walk; export columns (PMID, title, journal, year, evidence class, lens, group, plain-language finding, citation); who consumes the export; the claims-policy sign-off (evidence class never authorizes disease copy).\n**Acceptance.** Doc merged with Mathew's confirmation; A18b cites it.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:S2-W0"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D31",
    "title": "Bian coexistence + migration-window agreement",
    "description": "## D31 \u00b7 Bian coexistence + migration-window agreement \u00b7 A \u00b7 S \u00b7 S3-W0\n**Context.** RescaleOS is Bian's live production loop (NPT/BPT). S3 moves the engine, the loops and the skills onto the stack. Nothing starts without his explicit agreement.\n**Deliverables.** `docs/decisions/D31-bian-coexistence.md`: the cutover order (runtime on Postgres \u2192 loops on the stack \u2192 skills in-app \u2192 console retired), the coexistence period (both run, which is authoritative), the migration window, the failure path (back to the Mac), what must never be automated, and what changes for Bian day to day.\n**Acceptance.** Signed (comment) by Bian and Agon; A10/A11/A20 cite it.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:S3-W0"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D32",
    "title": "Hook + strategy expectations (Mathew + Cyrus)",
    "description": "## D32 \u00b7 Hook + strategy expectations \u00b7 V \u00b7 S \u00b7 S3-W0\n**Deliverables.** `docs/discovery/s3-hook-and-strategy.md` with Mathew + Cyrus: hook-session gate semantics (who approves, evidence packet, per-asset vs per-batch), what makes the coverage map actionable (which decisions, which existing artefacts they trust, what stays human), the brand-tone band and claim-validation rules for hook seeds; the exact rule for \"validated learning\" vs \"personal taste\" and what qualifies as a human-rated output for shared memory; and the six accelerator questions (`claude-code-accelerator-digest.md` \u00a76): which three artefacts he touches daily; what crosses from personal instinct into shared memory and who approves; losers/dead angles first-class or implied; swipe bank shared or personal; whether \"third time = workflow\" deserves a productised capture flow; whether \"one format first, LFS, blood sugar\" is a pilot tactic or the general rule.\n**Acceptance.** Doc merged with both confirmations; V14/V16/V18 cite it.",
    "labels": [
      "lane:V",
      "size:S",
      "wave:S3-W0"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D33",
    "title": "Script-card bridge + demand semantics (Sven + ops owner)",
    "description": "## D33 \u00b7 Script-card bridge + demand semantics \u00b7 S \u00b7 S \u00b7 S3-W0\n**Deliverables.** `docs/discovery/s3-script-bridge.md` with Sven + the ops owner: the Script card lifecycle (statuses, who flips), Demand Line provenance rules (`demand-week.ts`, `spawn.ts`), QA-gate ownership (`qa-gates/*`), what the bridge must preserve while ClickUp owns the Scripts list, the failure rules; Friday-call outcome ownership, manual exception paths, and the workaround rules that must survive until ClickUp is read-only.\n**Acceptance.** Doc merged; S16 cites the state map.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S3-W0"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D41",
    "title": "Launch pilot confirmation (Cyrus + media buyer)",
    "description": "## D41 \u00b7 Launch pilot confirmation \u00b7 S \u00b7 S \u00b7 S4-W0\n**Deliverables.** `docs/discovery/s4-launch-pilot-confirmation.md` with Cyrus + one active media buyer: the exact launch package, the proof-of-launch loop, which ClickUp fields they actually read, what they still rewrite by hand, what delays launches.\n**Acceptance.** Doc merged; S6 and S8b cite it.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S4-W0"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D42",
    "title": "Board authority + mirror-failure confirmation (Sven + ops owner)",
    "description": "## D42 \u00b7 Board authority + mirror-failure confirmation \u00b7 S \u00b7 S \u00b7 S4-W0\n**Deliverables.** `docs/discovery/s4-board-authority.md` with Sven + the ops owner: authoritative states per list (from the status templates in `fields-manifest.json`), the field definition for `ugcCreators` (none exists today), acceptable mirror lag, failure handling (`reconcile-cron.ts`, `qa-gates/*`), machine- vs human-owned fields per list (from `domain/ids.ts`), and the ordered list of the remaining 12 boards after the launch board.\n**Acceptance.** Doc merged with the ordered board list; S6, S26, S18, S19 cite it.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S4-W0"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D51",
    "title": "Billing + retention confirmation (ops owner)",
    "description": "## D51 \u00b7 Billing + retention confirmation \u00b7 S \u00b7 S \u00b7 S5-W0\n**Deliverables.** `docs/discovery/s5-billing-retention.md` with the ops owner: the manual-invoicing process, meter granularity (model calls, scraper/runtime jobs, bridge operations), invoice-export shape, retention windows per data class, deletion approvals, who may request export/deletion.\n**Acceptance.** Doc merged; S24, S25 cite it.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S5-W0"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D52",
    "title": "Roles + onboarding confirmation (Sven + Mathew)",
    "description": "## D52 \u00b7 Roles + onboarding confirmation \u00b7 V \u00b7 S \u00b7 S5-W0\n**Deliverables.** `docs/discovery/s5-roles-onboarding.md` with Sven + Mathew: role matrix across Production, Intelligence, Studio, Workspace; admin boundaries; workspace-creation flow (Rescale admin only); invite rules.\n**Acceptance.** Doc merged; S21, S23, V21 cite it.",
    "labels": [
      "lane:V",
      "size:S",
      "wave:S5-W0"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "D53",
    "title": "Security review scope confirmation",
    "description": "## D53 \u00b7 Security review scope confirmation \u00b7 A \u00b7 S \u00b7 S5-W0\n**Deliverables.** `docs/discovery/s5-security-scope.md`: review scope (tenancy, credentials, deletion safety, abuse cases, load envelopes), external vs internal reviewer, sign-off format.\n**Acceptance.** Doc merged; A17, S20 cite it.\n",
    "labels": [
      "lane:A",
      "size:S",
      "wave:S5-W0"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [],
    "links": []
  },
  {
    "key": "I1",
    "title": "Scraper LFS bridge",
    "labels": [
      "lane:A",
      "size:L",
      "wave:B"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [],
    "links": [],
    "description": "## I1 \u00b7 Scraper LFS bridge \u00b7 Agon \u00b7 L\n**Context.** Mathew wants ranked long-copy native ads from an Ads Library link and continuous lander breakdowns before S2 Intelligence exists.\n**Read first.** `scraper.js` (quick scrape, impression-rank order), `lib/deep-crawl/*`, `ads_browse` (`destination_url ILIKE` search), `lib/teardown/{worker,prompts}.cjs` (`teardown_model` setting), `lib/analysis/taxonomy.cjs` (format axis: no long-copy class), `lib/media-storage.cjs`, `tools/doc-render/` (self-contained HTML pattern).\n**Deliverables.** (1) Long-copy detection as a bridge field `ads.is_long_copy_native` (heuristic: `format \u2208 {Native Image, Static}` and `length(ad_copy) \u2265 900` chars, threshold in settings) \u2014 not a taxonomy change. (2) `POST /bridge/lfs/runs {ad_library_url, landers[], model}`: quick scrape + deep crawl of active ads \u2192 filter by landers on `destination_url` \u2192 rank (impression rank, then run span) \u2192 enqueue the new teardown tier `lfs_extract` (prompt: \"extract and analyze the long-copy native ad form: structure, hooks, word choice, reading grade level, persuasion devices, rhythm, CTA pattern; produce a 'write one for me' packet\"; `teardown_model` per run, default `x-ai/grok-4` via OpenRouter). (3) `GET /bridge/lfs/runs/:id` (status) and `GET /bridge/lfs/runs/:id/package`: a self-contained HTML report (ranked cards with image, copy, run span, destination, extraction) + per-ad JSON + media manifest. (4) Schedules: a run can be registered per brand + landers for daily re-execution.\n**Acceptance.** One run from the Primal Queen Ads Library link with both landers returns the ranked candidates and an HTML report that opens standalone; Grok extraction present per selected ad and reproducible from stored inputs; `node --test test/` extended.\n**Out of scope.** Native pages; taxonomy redesign; anything inside RescaleOS.",
    "wave": "B"
  },
  {
    "key": "I2",
    "title": "Primal Queen continuous job + export/drop",
    "labels": [
      "lane:A",
      "size:M",
      "wave:B"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "I1"
    ],
    "links": [],
    "description": "## I2 \u00b7 Primal Queen continuous job + export/drop \u00b7 Agon \u00b7 M \u00b7 depends I1\n**Deliverables.** Drop-target config (`BRIDGE_DROP_ROOT`: local path and/or Drive sync path); per run: `swipes/<brand>/<date>/` gets all candidates (HTML report + per-ad JSON + images), `copy/<brand>/` gets drafted LFS packets; `winners/` and `losers/` only on an explicit human flag; stable names, re-runs idempotent per report scope; daily schedule registered for both landers.\n**Acceptance.** Two consecutive daily runs produce the package in the target root with no duplicate ambiguity; Mathew opens the report from his folder.\n**Out of scope.** Folders as canonical state; any auto winner/loser classification.",
    "wave": "B"
  },
  {
    "key": "I3",
    "title": "RescaleOS /lfs skill",
    "labels": [
      "lane:V",
      "size:M",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "I1",
      "MC1"
    ],
    "links": [],
    "description": "## I3 \u00b7 RescaleOS `/lfs <ad-library-url>` skill \u00b7 Bian-side / Vince \u00b7 M \u00b7 depends I1, MC1\n**Read first.** `engine/skills/npt/SKILL.md` (slash-command pattern), `engine/skills/_doctrine/`, I1 API, the accelerator folder convention.\n**Deliverables.** `engine/skills/lfs/SKILL.md`: submit run (`--landers`, `--model`) \u2192 poll \u2192 fetch package \u2192 file into the workspace (`swipes/`, `copy/`) \u2192 print report path + top ranked ads \u2192 optional `--draft`: write one LFS using the stored extraction packet as the only source. No scraping, no Grok inside RescaleOS.\n**Acceptance.** One end-to-end invocation from an Ads Library URL to filed artefacts; the draft cites the stored extraction.",
    "wave": "B"
  },
  {
    "key": "I4",
    "title": "Miguel's feedback \u2192 rules.md",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [],
    "links": [],
    "description": "## I4 \u00b7 Miguel's feedback \u2192 `rules.md` \u00b7 Vince \u00b7 S \u00b7 depends Miguel's notes\n**Deliverables.** `rules.md` in Mathew's structure: hard rejects \u00b7 tendencies \u00b7 before/after pairs \u00b7 reaction-log format \u00b7 promotion rule (one reaction \u2192 repeated pattern \u2192 reusable rule); sections for AI animated ads and cartoon ads; referenced by the relevant RescaleOS skills. **Acceptance.** File exists; at least one reaction \u2192 rule example.",
    "wave": "B"
  },
  {
    "key": "M1",
    "title": "Static-ads per-user settings/keys migration",
    "description": "## M1 \u00b7 Per-user settings/keys migration \u00b7 S \u00b7 `feat/v/m1-settings-migration`\n**Context.** Each user's encrypted OpenRouter key and generation settings must survive the move and become workspace + user scoped.\n**Read first.** `schema.sql` L159\u2013174 (`settings` columns incl. `api_key_encrypted`, Canva tokens), `app/backend/encryption.py`, `settings_service.py`, `config.py::load_settings_from_db` L189\u2013216.\n**Deliverables.** Migration `core/007_user_settings.sql`: `core.user_settings(workspace_id, user_id, key text, value_encrypted text, updated_at, pk(workspace_id,user_id,key))`; data migration in `production/002_settings_to_core.sql` (copy `settings` rows for every user \u2192 keys `openrouter_api_key`, `claude_model`, `default_aspect_ratio`, `images_to_generate`, `images_to_keep`, `canva_*`; same Fernet ciphertext, same `ENCRYPTION_KEY`); `settings_service.py` reads/writes `core.user_settings` for the current user + workspace; `load_settings_from_db(user_id)` unchanged in signature, new source. `instructions` table stays in `production` (seed for a later slice overlays).\n**Acceptance.** `pytest app/backend/tests/test_settings_migration.py` (round-trip of a known ciphertext); on staging every imported user's settings page shows their values; a generation uses the user's key (log line masked). \n**Out of scope.** Key rotation; per-workspace provider keys (that is V4 providers).",
    "labels": [
      "lane:V",
      "size:S",
      "wave:W3"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S1a",
      "V3"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/m1-settings-migration"
      }
    ]
  },
  {
    "key": "M2",
    "title": "R2 prefix reconciliation (ADR + job)",
    "description": "## M2 \u00b7 R2 prefix reconciliation (ADR) \u00b7 S \u00b7 `docs/v/m2-r2-prefix` (with Agon)\n**Read first.** `app/backend/services/r2_service.py` (key construction), lock doc \u00a74 tenancy rule, PRD \u00a76.\n**Deliverables.** `docs/decisions/ADR-009-r2-key-prefix.md`: all new writes use `ws/<workspace_id>/<existing key>`; existing objects keep their keys; a a later slice job copies + verifies + re-points (no deletes until verified); `r2_service.py` gains the prefix on write (`build_key(workspace_id, \u2026)`) with a unit test; reads accept both.\n**Acceptance.** ADR merged; `pytest app/backend/tests/test_r2_prefix.py` green (new write prefixed; old key still readable).\n",
    "labels": [
      "lane:V",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S1a"
    ],
    "links": []
  },
  {
    "key": "M3",
    "title": "Email delivery",
    "description": "## M3 \u00b7 Email delivery \u00b7 S \u00b7 W6 \u00b7 Agon \u00b7 `feat/a/m3-email` (S1-rev)\n**Read first.** ecomprofits `packages/mailers` (`MAILER_PROVIDER \u2208 nodemailer|resend`, env `EMAIL_*`, `RESEND_API_KEY`).\n**Deliverables.** `apps/api/core/mail/mailer.py` with the same provider switch; templates invite/magic/reset (sender `app@rescale.media`); staging sandbox; `MAIL_DRY_RUN=1` logs instead of sending; S1b's invites go out through it.\n**Acceptance.** `pytest apps/api/tests/core/test_mail.py`; on staging one imported user receives the invite and signs in (screenshot).",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W6"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "S1b"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/m3-email"
      }
    ]
  },
  {
    "key": "M4",
    "title": "Cross-box monitoring runbook",
    "description": "## M4 \u00b7 Cross-box monitoring runbook \u00b7 S \u00b7 W6 (S1-rev)\n**Deliverables.** Extend `infra/synthetic/check.sh` with: old static-ads host (301 after go-live), `meta-ads.rescale.media` and the console URL (200 \u2014 they stay live as standalones), Railway workos `/health`; `docs/runbooks/LEGACY_HOSTS.md`: what runs where (production box vs rescale box vs Railway), who to call, which crons stay.\n**Acceptance.** Rows on `/status`; runbook merged.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W6"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "X3"
    ],
    "links": []
  },
  {
    "key": "M5",
    "title": "Retention / archive plan",
    "description": "## M5 \u00b7 Retention / archive plan \u00b7 S \u00b7 W5 (S1-rev)\n**Deliverables.** `docs/ops/RETENTION.md`: old static-ads host read-only for the agreed post-cutover retention window, archive point (final dump + R2 prefix manifest), deletion owner + trigger (Agon, after the S1 exit gate and the retention window); the standalone scraper and console are NOT in scope (they stay live until S2/S3); Skynet backup retention (20 days local, Storage Box per its policy), pre-deploy dump folder policy (alert at 20 GB, never auto-pruned).\n**Acceptance.** Doc merged; Agon and Vince acknowledged.\n\n",
    "labels": [
      "lane:S",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "X5"
    ],
    "links": []
  },
  {
    "key": "M6",
    "title": "Comms + training pack",
    "description": "## M6 \u00b7 Comms + training pack \u00b7 S \u00b7 W6 (S1-rev)\n**Deliverables.** `docs/comms/` one page per audience: Bian (nothing changes for RescaleOS; the console stays where it is; Studio merge comes with S3 and needs his agreement), Cyrus + creative (Production lives at `app.rescale.media`, one login, exports carry the creative code, the package appears on the Launch card), media buyers (nothing changes in ClickUp; the package on the card), ops/research (scraper unchanged, reachable from the shell link), leadership (S1 exit gate met, the slice sequence). Sessions booked.\n**Acceptance.** Pages merged; invites sent (screenshot).",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W6"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "X5"
    ],
    "links": []
  },
  {
    "key": "MC1",
    "title": "RescaleOS model-configuration boundary",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [],
    "links": [],
    "description": "## MC1 \u00b7 RescaleOS model-configuration boundary \u00b7 Bian-side / Vince \u00b7 S\n**Deliverables.** `docs`/settings state the true boundary: session model choice = Claude models via `setModel` (`sessions.model`, the usage-fallback chain); non-Claude models (Grok etc.) are reachable only through the scraper/OpenRouter tiers (`teardown_model`, `lfs_extract`) or a dedicated MCP; the `/lfs` skill exposes `--model` as the scraper tier's model, not the session's. **Acceptance.** No \"any model\" claim remains in RescaleOS docs; `/lfs --model x-ai/grok-4` sets the scraper run's model.",
    "wave": "B"
  },
  {
    "key": "R1",
    "title": "PubMed doctrine file",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [],
    "links": [],
    "description": "## R1 \u00b7 PubMed doctrine file \u00b7 Bian-side / Vince \u00b7 S\n**Deliverables.** `engine/skills/_doctrine/evidence-contract.md`: the evidence-class table from `pubmed-evidence-layer-plan.md` \u00a72 (`strong / moderate / preclinical / case-report / narrative-review / traditional-use / unclassified`), the claims policy (evidence class \u2260 permission; DSHEA gate; RAW \u2192 CA derivation; `form-mismatch` blocks claims), citation format, and transport order WebFetch \u2192 MCP \u2192 paste. **Acceptance.** File merged; referenced from `CLAUDE.md` routing.",
    "wave": "B"
  },
  {
    "key": "R2",
    "title": "RescaleOS /evidence-layer skill",
    "labels": [
      "lane:V",
      "size:M",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "R1"
    ],
    "links": [],
    "description": "## R2 \u00b7 RescaleOS `/evidence-layer` skill \u00b7 Bian-side / Vince \u00b7 M \u00b7 depends R1\n**Deliverables.** Skill in `engine/skills/evidence-layer/SKILL.md`: inputs mechanism, concern, ingredient(s), market-language seeds; six lenses (mechanism, root cause, symptoms, ingredients, failed solutions, desired outcomes) as PubMed E-utilities queries over WebFetch first; outputs M0\u2013M9 (evidence table with class + citation, mechanism angles, fact-based hooks, authority statements, belief-shifting arguments, symptoms to call out, why other solutions fail, proof anchor, combine step with Reddit VOC); writes `evidence_report_{product}.md`; dry-run fixture. **Acceptance.** Fixture run classifies the sample studies correctly; the report is written in the workspace and consumed by the downstream skills.",
    "wave": "B"
  },
  {
    "key": "R3",
    "title": "BPT consumer of evidence",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "R2",
      "R6"
    ],
    "links": [],
    "description": "",
    "wave": "B"
  },
  {
    "key": "R4",
    "title": "Offer-brief / belief-doc consumers",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "R2",
      "R6"
    ],
    "links": [],
    "description": "",
    "wave": "B"
  },
  {
    "key": "R5",
    "title": "Scriptwrite feed from evidence",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "R2",
      "R6"
    ],
    "links": [],
    "description": "",
    "wave": "B"
  },
  {
    "key": "R6",
    "title": "Prove /evidence-layer on one product",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "R2"
    ],
    "links": [],
    "description": "## R6 \u00b7 Prove on one product \u00b7 S \u00b7 depends R2 \u2014 **Acceptance.** One living evidence report for the chosen product; retro edits applied to R1/R2.\n## R3 / R4 / R5 \u00b7 Consumers \u00b7 S each \u00b7 depends R2, R6 \u2014 BPT Phase 2.5 reads the evidence output; belief rows cite evidence rows or are marked as non-evidence-derived; scriptwrite feeds from M2/M3 with RAW/CA handling. **Acceptance.** One BPT run, one offer brief and one script show cited evidence.",
    "wave": "B"
  },
  {
    "key": "R7",
    "title": "Console MCP wiring",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "R6"
    ],
    "links": [],
    "description": "## R7 \u00b7 Console MCP wiring \u00b7 Bian-side / Vince \u00b7 S \u00b7 depends R6 (optional accelerator)\n**Deliverables.** `console/src/server/env.ts` (`getPubmedMcp()`), `console/src/server/agent/managedSession.ts` (`mcpServers.pubmed`, `mcp__pubmed__` pre-trust), `console/.env.docker` template, `DEPLOY.md`; wired to the standalone PubMed MCP when that endpoint exists. **Acceptance.** A console session lists the four PubMed tools and `/evidence-layer` reports transport `mcp`.",
    "wave": "B"
  },
  {
    "key": "R7a",
    "title": "PubMed scraper adapter + MCP",
    "labels": [
      "lane:A",
      "size:M",
      "wave:B"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "R6"
    ],
    "links": [],
    "description": "## R7a \u00b7 PubMed scraper adapter + MCP \u00b7 Agon \u00b7 M \u00b7 depends R6 (optional accelerator)\n**Deliverables.** `lib/market-intel/pubmed-{client,normalize,classifier,worker,corpus}.cjs`, `lib/mcp/pubmed-mcp.cjs` at `/mcp/pubmed`, wired for RescaleOS use. **Acceptance.** The endpoint serves the four tools and produces the same report contract as WebFetch.",
    "wave": "B"
  },
  {
    "key": "R8",
    "title": "RescaleOS maps + memory flip",
    "labels": [
      "lane:V",
      "size:S",
      "wave:B"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "R2",
      "R6"
    ],
    "links": [],
    "description": "## R8 \u00b7 RescaleOS maps + memory flip \u00b7 S \u00b7 depends R2, R6 \u2014 routing, `supplements/CONTEXT.md`, `engine/memory/evidence-layer.md` say PubMed is live and is evidence, not VoC.",
    "wave": "B"
  },
  {
    "key": "S0a",
    "title": "Scrubbed-import prep for workos",
    "description": "## S0a \u00b7 Scrubbed-import prep for workos \u00b7 S \u00b7 `chore/s/s0a-scrub-workos` (with Sven)\n**Context.** The rescale-workos repo has live credentials committed on purpose (co-founder handoff). The monorepo import (A1a) must not carry one of them. Decision already taken: no rotation now; credentials are discontinued when workos leaves Railway (S17, S4).\n**Read first.** `rescale-workos/CLAUDE.md` lines 40\u201382 (`## Access and credentials`: ClickUp, AI/comms, Google, Postgres, Railway); `rescale-workos/.gitignore` lines 1\u201311 (says credentials are committed; `secrets/` is NOT ignored); `rescale-workos/secrets/` (\u2248110 tracked files \u2014 `SESSION-PRIMING.md`, `google-service-account.json`, `field-create.curl`, `automation-group.curl`, `apply-migration-00{2,3,4,6}.mjs`, `ecomprofits-role-test.mjs`, `reseed-globals-2026-07-09.ts`, `p*/w*-*.mjs` probes); `rescale-workos/service/.env.example` (the env names, no values).\n**Deliverables.** (1) `docs/security/workos-credential-inventory.md` in the *old* repo (private): one row per credential \u2014 name, where it lives today (CLAUDE.md line / secrets file / Railway variable), owner, discontinuation date S17. No values in the doc. (2) Under `infra/secret-scan/`, create both `gitleaks.toml` and `patterns.txt`. `gitleaks.toml` is the single config used by both the manual scan and the pre-commit hook; it must include the stock gitleaks rules plus the custom patterns from `patterns.txt` (one custom regex per known value: first 8 characters + length, never the value). (3) The scrubbed working tree: `rescale-workos` checkout with `secrets/` removed, `CLAUDE.md` lines 40\u201382 replaced by one paragraph \"Credentials live in Railway variables and the 1Password vault *Rescale workos*; the old repo stays private as the archive until S17\", `.gitignore` gaining `secrets/`, `*.pem`, `*.key` kept. Hand the tree's path to Agon. (4) Pre-commit hook config (`.pre-commit-config.yaml` with gitleaks using the pattern file) ready for A1b.\n**Interfaces.** Exposes the scrubbed tree + pattern file to A1a/A1b.\n**Steps.** Inventory with Sven (30 min) \u2192 write patterns \u2192 `gitleaks detect --source <scrubbed-tree> --config infra/secret-scan/gitleaks.toml` \u2192 fix until zero hits \u2192 grep the tree for each first-8-chars \u2192 hand over.\n**Acceptance.** Paste in the TechDesk task: (a) `gitleaks detect` output on the scrubbed tree = 0 findings; (b) a deliberate test commit containing one known value is rejected by the pre-commit hook (terminal output); (c) inventory doc link.\n**Out of scope.** Rotating anything; Railway changes; the monorepo import itself (A1a).",
    "labels": [
      "lane:S",
      "size:S",
      "wave:W0"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": [
      {
        "label": "branch",
        "url": "chore/s/s0a-scrub-workos"
      }
    ]
  },
  {
    "key": "S0b",
    "title": "Pre-pilot discovery (launch process, per brand)",
    "description": "## S0b \u00b7 Pre-pilot discovery (per-brand launch process) \u00b7 S \u00b7 M \u00b7 S4-W0 \u00b7 `docs/s/s0b-launch-discovery`\n**Context.** The launch board is the *proposed* first native board. Gate G2 confirms or re-targets it. Without a real map of how each brand launches statics today, S8's data model is a guess.\n**Read first.** Functional design \u00a73.6 (launch with a creative code) and \u00a77 item 10; Native Workspace PRD (`rescale-workos-native-workspace-prd.md`) phases A\u2013D; workos `service/src/domain/ids.ts` lines 187\u2013210 (the 23 Launch fields) and `service/src/modules/cascade/launch-outcome.ts` (outcome statuses); `rescale-workos-handoff.md`.\n**Deliverables.** `docs/discovery/launch-process.md`: (1) one table per brand with columns request source \u00b7 approval chain \u00b7 asset packaging (Drive/ClickUp/other) \u00b7 naming pattern used \u00b7 account destination \u00b7 who publishes \u00b7 time from request to live \u00b7 where it breaks (with an example); (2) a list of the fields a media buyer actually reads before launching (compare with the 23 ClickUp fields \u2014 which are dead); (3) the proposed pilot product + media buyer, with why; (4) two interview notes (media buying, ops), 45 min each.\n**Interfaces.** Feeds S6 (G2 write-up) and S8 (data model must cite the table).\n**Steps.** Book both interviews; run them plus one screen-share of a real launch; draft; then S6.\n**Acceptance.** Doc merged; every active launch brand in scope has a row; pilot product and media buyer named; Agon and Vince have read it (comment on the task).\n**Out of scope.** Designing the board UI (V9); ClickUp changes.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S4-W0"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": [
      {
        "label": "branch",
        "url": "docs/s/s0b-launch-discovery"
      }
    ]
  },
  {
    "key": "S10",
    "title": "Creative-code loop E2E with one real ad",
    "description": "## S10 \u00b7 Creative-code loop E2E with one real ad \u00b7 M \u00b7 W4 \u00b7 `chore/s/s10-e2e-loop` (S1-rev)\n**Deliverables.** `docs/runbooks/LOOP_E2E.md` with proofs at each hop: (1) Production export with a code (V7) \u2192 (2) `POST /launches/:id/package` on the real Launch card of the pilot creative (S8a) \u2192 (3) the media buyer uploads the ad with the assembled ad name \u2192 (4) `select date, ad_id, nc_creative_code, amount_spent_eur, purchase_value_eur, winner_loser_status from meta_ads_dashboard_view where nc_creative_code = '<code>' order by date desc limit 20;` \u2192 (5) `core.performance_snapshots` row (S7) \u2192 (6) `GET /api/lineage/chain?type=creative_code&ref=<code>`. Mismatches fixed in the owning card the same day.\n**Acceptance.** Runbook with all six proofs on staging with a real ad id; Agon signs the task.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W4"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "V7",
      "S8a",
      "S7"
    ],
    "links": [
      {
        "label": "branch",
        "url": "chore/s/s10-e2e-loop"
      }
    ]
  },
  {
    "key": "S11",
    "title": "Pilot SOP + walkthrough \u2192 G3",
    "description": "## S11 \u00b7 Pilot SOP + walkthrough \u2192 G3 \u00b7 S \u00b7 S \u00b7 S4-W3\n**Context.** The launch-board pilot may only move to production dual run after the buyer workflow, reconciliation behaviour and rollback path are proven on one named product.\n**Read first.** S6 (`docs/decisions/G2-workspace-board-order.md`), S8b API + mirror contract, V9 UI, S10 loop E2E runbook, X5 rollback drill, S12 migration rehearsal, D41.\n**Deliverables.** `docs/pilot/launch-board-sop.md` (who does what, when ClickUp still rules, how conflicts are resolved, proof-of-launch loop); recorded walkthrough with the named media buyer on staging; pilot-product data-hygiene checklist (assets, naming, assignees, package fields, mirror health); final reconciliation rerun over the pilot window with a diff report (0 unexplained diffs); `docs/decisions/ADR-004-pilot-go-live.md` with go / no-go and the exact feature-flag state for production.\n**Acceptance.** SOP merged; walkthrough recording attached; reconciliation report with 0 unexplained diffs; ADR-004 merged before S4-W4 starts.\n**Out of scope.** Building the board; ClickUp schema changes; boards beyond Launches.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S4-W3"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S8b",
      "V9"
    ],
    "links": []
  },
  {
    "key": "S12",
    "title": "Migration dry run (production PG) + rollback scripts",
    "description": "## S12 \u00b7 Migration dry run (production PG) + rollback scripts \u00b7 M \u00b7 `chore/s/s12-migration-drill`\n**Context.** Production's data (63 tables, `schema.sql`) moves from the in-container Postgres to unified PG at go-live. The drill proves it twice before it counts.\n**Read first.** V3 spec (baseline + init guard); static-ads `docker/init-db.sh` (init guard L38, re-runs `schema.sql` every boot L72\u201397); `schema.sql`; implementation PRD \u00a75 (migrate step guarantees) and \u00a76.\n**Deliverables.** `infra/migrate/production/`: `dump.sh` (`pg_dump -Fc` from the old host container), `restore.sh` (into unified `production` schema on staging), `checksums.py` (introspects each table's real primary-key columns from `pg_index`/`pg_attribute`, then writes `checksums.sql` with deterministic per-table queries: `count(*)` for every table, and for the 20 largest tables an ordered row hash `md5(string_agg(t::text, '|' order by <real pk columns>))`; never a literal `order by pk`), `verify.sh` (old vs new checksum table \u2192 `docs/runbooks/MIGRATION_PRODUCTION.md` with timings), `rollback.sh` (repoint `DATABASE_URL` to the old container + restore pre-migrate dump), all idempotent. Run the full drill on staging twice.\n**Acceptance.** Runbook with two drill runs, all checksums equal, timings; rollback drill restores the pre-migrate dump under 15 min. Post the checksum table.\n**Out of scope.** The go-live run itself (X7, Agon + Sead).",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W5"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "V3",
      "A6"
    ],
    "links": []
  },
  {
    "key": "S13",
    "title": "Cross-tenant isolation tests",
    "description": "## S13 \u00b7 Cross-tenant isolation tests \u00b7 S \u00b7 W5 \u00b7 `test/s/s13-isolation` (S1-rev)\n**Deliverables.** `apps/api/tests/core/test_isolation.py`: workspaces A and B; for every route under `/api/creative-codes`, `/api/lineage`, `/api/performance`, `/api/bindings`, `/api/admin/*` and the production read routes (`GET /api/winners`, `/api/winners/{product}`, `/api/products/{product}/creatives`, `/api/products/{product}/creatives/insights`, `/api/products/{product}/sessions`, `/api/products/{product}/sessions/{id}`, `/api/video-storyboard/sessions`, `/api/video-storyboard/sessions/{id}`, `/api/localize-video/sessions`, `/api/localize-video/sessions/{id}`), user A on a B-owned id \u2192 404/403; table in `docs/SECURITY.md`; CI required check.\n**Acceptance.** Green in CI; 0 leaks in the table.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S1a",
      "S3",
      "S4",
      "S7",
      "V4",
      "V10"
    ],
    "links": []
  },
  {
    "key": "S14",
    "title": "Inbox + dispatch",
    "description": "## S14 \u00b7 Inbox + dispatch \u00b7 S \u00b7 M \u00b7 S3-W4\n**Read first.** `core.events_outbox` (S4); functional design \u00a73.2, user stories 9, 15, 27.\n**Deliverables.** `core.inbox_items(workspace_id, owner_id, kind, source_ref, evidence jsonb, state \u2208 open|done|dismissed, created_at)`; `core.chat_feedback_captures(id, workspace_id, source, external_id, channel_id, author_hash, text, attachment_ref, posted_at, ingested_at, processed_at)`; port the RescaleOS feedback pollers into Skynet for registered team channels (Telegram `getUpdates` offset, Discord REST `GET /channels/{id}/messages?after=` with attachment download) so team-feedback channels feed the inbox/dispatch flow here, while customer/community channels remain A23 market-intel sources; producers: script comments/tags, teardown tags, hook kills, chat-feedback captures, alerts (A13b), bridge actions, and manual rough capture (drop a file, link or half-thought into the inbox unsorted, then process or file it later); dispatcher rules (who gets what); UI inbox in the shell with quick actions (\"assign to X\", \"start a script\", \"process now\", \"file later\"); Telegram/Discord quick replies land here (delivered by S27, acted on by A24); `pytest apps/api/tests/studio/test_inbox_dispatch.py`.\n**Acceptance.** Test green; one staging item with owner, evidence packet, resolution.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S3-W4"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "A21",
      "S16"
    ],
    "links": []
  },
  {
    "key": "S15",
    "title": "Lineage producers (scraper, studio, workos) + coverage store v0",
    "description": "## S15 \u00b7 Lineage producers (scraper, studio, workos) + coverage store v0 \u00b7 S \u00b7 M \u00b7 S3-W5\n**Deliverables.** Lineage nodes/edges from: Intelligence (ad/family \u2192 teardown \u2192 brief), Studio (brief \u2192 script \u2192 session \u2192 gate), workos bridge (script card, creative card, launch); `core.coverage_facts(workspace_id, product_id, segment, awareness, angle, source \u2208 label|creative_code|performance, count, evidence_refs[])` filled from labels (A9 data), creative codes and snapshots; validated-memory writeback: `core.validated_learnings(workspace_id, product_id, kind \u2208 winner|dead_angle|rule, statement, rule_taught, evidence_refs[], source_event, confirmed_by, created_at)` written only from human-confirmed winners **and confirmed losers** (S9 \u2014 a dead angle records the rule it taught, first-class like a winner) or human-rated outputs; promotion threshold per the accelerator data dictionary (second occurrence = pattern proposal, third = rule/workflow proposal) \u2014 personal taste signals from V17/V19 stay in the instinct stores and never write here; the SuperMemory writer moves behind this table; `pytest apps/api/tests/core/test_lineage_producers.py`.\n**Acceptance.** Test green; one chain teardown \u2192 script \u2192 export \u2192 performance \u2192 winner candidate; one confirmed winner writes one validated-learning record; one confirmed loser writes one `dead_angle` record with `rule_taught`; one unconfirmed edit writes none.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S3-W5"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S14",
      "S16"
    ],
    "links": []
  },
  {
    "key": "S16",
    "title": "Script-entity bridge to ClickUp Script cards + demand-line hand-off",
    "description": "## S16 \u00b7 Script-entity bridge to ClickUp Script cards + demand-line hand-off \u00b7 S \u00b7 L \u00b7 S3-W3\n**Read first.** D33; workos `forward.ts:186` (`handleScriptApproved`), `LISTS.scripts`, `demand-week.ts`, `spawn.ts`, `qa-gates/*`; S8a bridge module.\n**Deliverables.** Bridge endpoints in workos: `POST /scripts` (create the ClickUp Script card from a Skynet script entity, structural fields only per invariant 8c), `PATCH /scripts/:id` (human-owned fields), webhook \u2192 Skynet on Script status changes (approved \u2192 Creative spawn continues in workos); `POST /demand-lines` (from allocation, S3-W6) using the spawn path; Skynet keeps `studio.scripts.clickup_task_id`; reconciliation report both ways; `vitest services/workos/tests/script-bridge.spec.ts`.\n**Acceptance.** Test green; bidirectional transcript for one script (Skynet \u2192 card \u2192 approved in ClickUp \u2192 Skynet sees it).",
    "labels": [
      "lane:S",
      "wave:S3-W3",
      "size:L"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "A10",
      "D33"
    ],
    "links": []
  },
  {
    "key": "S17",
    "title": "workos host move into the stack + credential discontinuation",
    "description": "## S17 \u00b7 workos host move into the stack + credential discontinuation \u00b7 S \u00b7 L \u00b7 S4-W6\n**Read first.** S0a inventory; `service/.env.example` + `config.ts` (`EnvSchema`); Railway config; `scripts/register-clickup-webhook.mjs`; `rescale_service` role on ecomprofits.\n**Deliverables.** `services/workos` deployed as service `workos` in the Skynet stack (Node 24, limits), env from `.env.production`; new ClickUp bot token, OpenRouter key, Discord token, AssemblyAI key, Google service account, ecomprofits `rescale_service` password \u2014 old values revoked (report); webhook re-registered to the new host; cron parity (reconcile, platform-sync, bounty, group-sync) plus the media proxy (`GET /media/:fileId`) verified against a 24-h Railway side-by-side, with an explicit parity check over every retained module (webhooks, reconcile, platform-sync, bounty, group-sync, media proxy, notification/rules); rollback = Railway kept warm for the rollback window then deleted; `docs/runbooks/S4-workos-host-cutover.md`.\n**Acceptance.** Parity checklist green; revoked-credential report; Railway off after the window.",
    "labels": [
      "lane:S",
      "size:L",
      "wave:S4-W6"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S11",
      "S26",
      "S18"
    ],
    "links": []
  },
  {
    "key": "S18",
    "title": "Board batch 3 API + mirrors: products, competitors, researchIntake, ripSources",
    "description": "## S18 \u00b7 Board batch 3 API + mirrors: products, competitors, researchIntake, ripSources \u00b7 S \u00b7 L \u00b7 S4-W5\n**Context.** Batch 3 moves the entity/intake boards native so product setup, competitor tracking, intake and rip sourcing no longer depend on ClickUp as the active workflow store.\n**Read first.** D42, A13a/A13b, A15, the workos reconcile rules (`reconcile-cron.ts`, provenance sweeps), the economics sync modules (`modules/economics/*`, `thresholds.ts`).\n**Deliverables.** Native stores + mirrors for the four lists; products link to the Skynet product (A15) and the naming product number; competitors link to A13 entities; research intake feeds A14; rip sources feed Expand; provenance/reconcile sweeps ported; `M8` Sheet sync + unit economics parity (COGS, margin %, breakeven ROAS, target ROAS 15/20 %, target CPP, currency \u2014 the sync/reconcile rules that fill those fields today, sourced from the economics modules) on Products; `vitest services/workos/tests/board-batch3.spec.ts`.\n**Acceptance.** Test green; dual-run report; owner sign-off.\n**Out of scope.** Demand, Scripts, Creatives, Launches, the final read-only cutoff, company-ops lists.",
    "labels": [
      "lane:S",
      "wave:S4-W5",
      "size:L"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S26",
      "D42"
    ],
    "links": []
  },
  {
    "key": "S19",
    "title": "Final batch API + ClickUp read-only cutoff: funnelsLanders, ugcCreators, emailFlows, campaigns, adsets",
    "description": "## S19 \u00b7 Final batch API + ClickUp read-only cutoff: funnelsLanders, ugcCreators, emailFlows, campaigns, adsets \u00b7 S \u00b7 L \u00b7 S4-W7\n**Context.** The final batch completes the replacement of the 13 creative-workflow lists and turns ClickUp into a read-only rollback surface for those lists only.\n**Read first.** D42 (incl. the `ugcCreators` schema), S17 host-move runbook, the naming assemblers (`assemble.ts`, `funnelish-name.ts`, `klaviyo-name.ts`, `campaign-name.ts`), `platform-sync/*`, `modules/rules/*`.\n**Deliverables.** Native stores + mirrors for the five lists (campaign/adset naming via the assembler; funnel availability + Funnelish/Klaviyo namers ported); then the 13 lists set read-only in ClickUp (statuses locked, a banner comment), the mirror kept as read-only rollback for the retention window; company-ops lists untouched; `M7` KPI + `M9` Rules parity and the Phase-8 workos features for the in-scope lists: launch outcomes propagation, fulfillment tracker, winner-iteration demand, URL rip engine, auto-CBO / increment fields, Discord/group-sync notification rules; the `ugcCreators` schema from D42 implemented here; `vitest services/workos/tests/board-batch4.spec.ts`.\n**Acceptance.** Test green; read-only cutoff checklist; bounty still runs from the native stores.\n**Out of scope.** Company-ops lists, payroll/bounty replacement, non-creative Workspace programs.",
    "labels": [
      "lane:S",
      "wave:S4-W7",
      "size:L"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S17",
      "D42"
    ],
    "links": []
  },
  {
    "key": "S1a",
    "title": "Core auth + workspaces + contract",
    "description": "## S1a \u00b7 Core auth + workspaces + contract \u00b7 M \u00b7 `feat/s/s1a-core-auth`\n**Context.** One login for every surface. Core issues RS256 tokens; every other service only verifies. This is the first cross-lane blocker: V1, V2a, V6b, A3, A5-lite all consume it.\n**Read first.** Lock doc \u00a74.1 (the contract \u2014 claims, cookies, refresh rotation, `tv` logout propagation, service tokens, legacy mapping); static-ads `app/backend/auth.py` (today's `create_token`, `get_current_user` L61\u201367 reading Bearer or `?token=`, `token_version` check L74\u201388); `app/backend/main.py` L75 (`auth.router` at `/api/auth`) and L78 (`auth_dep`); `app/backend/db.py` (`get_pool`, `get_cursor`); `schema.sql` `users` table.\n**Deliverables.**\n1. Migration `db/migrations/unified/core/001_core.sql`: `core.workspaces(id uuid pk, slug unique, name, settings jsonb, created_at)`, `core.users(id uuid pk, email citext unique, name, password_hash, token_version int default 0, is_active bool, created_at)`, `core.permissions(key text pk, module, action, description)`, `core.roles(id uuid pk, workspace_id uuid null, key, name, is_system bool default false; unique key where workspace_id is null; unique (workspace_id, key))`, `core.role_permissions(role_id, permission_key, pk both)`, `core.workspace_members(workspace_id, user_id, pk both)`, `core.workspace_member_roles(workspace_id, user_id, role_id, pk all three)` \u2014 roles are data, a member may hold several, effective permissions = **union of allows** (no deny rules), `core.refresh_tokens(id uuid pk, user_id, family_id uuid, token_hash, expires_at, revoked_at, replaced_by uuid)`, `core.feature_flags(workspace_id, key, enabled bool, pk(workspace_id,key))`, `core.schema_migrations(version text pk, checksum text, applied_at)`. Seed: workspace `rescale`, admin user from `ADMIN_EMAIL`/`ADMIN_PASSWORD`; the six system roles `admin, strategist, media_buyer, editor, researcher, viewer` and the S1 permission catalogue: `admin.workspace.manage, admin.members.manage, admin.settings.manage, admin.audit.read, intelligence.read, intelligence.write, intelligence.teardown.run, intelligence.research.promote, studio.read, studio.write, studio.approve, production.read, production.write, production.export, workspace.read, workspace.launch.write, performance.read` with the role \u2192 permission mapping in `packages/contracts/auth/README.md`.\n2. Module `apps/api/core/auth/`: `keys.py` (load `AUTH_JWT_PRIVATE_KEY_PEM`/`PUBLIC`, `kid` = sha256 prefix), `tokens.py` (`issue_access(user, ws) \u2192 jwt`, `issue_refresh`, `rotate_refresh`, `revoke_family`, `issue_service(name, ws)`), `verify.py` (`verify_access_token(token) \u2192 Claims`, checks `kid`, `exp`, `typ`, `iss`, and `tv` against a 60-s cached lookup), `deps.py` (`current_user`, `require_permissions(*keys)` \u2014 every route gate checks permissions, never role names; `require_roles(*roles)` exists only as a thin compatibility shim during the port), `router.py`.\n3. Routes under `/api/auth`: `POST /login {email,password}` \u2192 sets `rs_access` (12 h) + `rs_refresh` (30 d, path `/api/auth`) cookies, HttpOnly/Secure/SameSite=Lax, `Domain` = exact host from `PUBLIC_HOST`; `POST /refresh` (rotation; reuse of a revoked token revokes the family); `POST /logout` (revokes family, bumps `token_version`, clears cookies); `GET /me` \u2192 `{id, email, name, ws, ws_slug, roles, permissions, feature_flags?}` for the current session (`roles` = the member's role keys; `permissions` = the effective union resolved server-side with a short cache \u2014 the canonical source for UI gating; the JWT carries roles only, never permissions) \u2014 the canonical shape V2a/V6 use for the workspace switcher and route gating; `GET /jwks`; `GET /session-token` (returns the current access JWT as JSON for the static-ads bridge, same-origin only); `GET /tv/:sub` (internal, service token); `POST /service-token` (admin only).\n4. `packages/contracts/auth/`: `access-claims.schema.json`, `service-claims.schema.json`, `samples/` (valid, expired, wrong-kid, revoked-tv \u2014 signed with a test keypair committed under `samples/test-keys/`), `verify.py`, `verify.ts` (jose), `verify.node.cjs`, and `README.md` (claim table + cookie table + legacy role mapping).\n**Interfaces.** Exposes the cookie names, claims, JWKS, `/session-token`, `/tv/:sub`. Consumed by V1, V6b, A5-lite, A3, X1.\n**Steps.** Migration \u2192 keys/tokens \u2192 routes \u2192 contracts package + samples \u2192 tests \u2192 README.\n**Acceptance.** `pytest apps/api/tests/core/test_auth.py` green (login sets both cookies; refresh rotates; reuse revokes family; logout bumps `tv`; `/me` 401 after logout); `python packages/contracts/auth/verify.py packages/contracts/auth/samples/*.jwt` and `node packages/contracts/auth/verify.node.cjs packages/contracts/auth/samples/*.jwt` print the same 4 verdicts (valid / expired / wrong-kid / revoked-tv). Post both outputs.\n**Out of scope.** Invite/magic link/reset (S1b); users UI (V11); RLS.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W1"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s1a-core-auth"
      }
    ]
  },
  {
    "key": "S1b",
    "title": "Invite/magic link/reset + user import",
    "description": "## S1b \u00b7 Invite / magic link / reset + user import \u00b7 M \u00b7 `feat/s/s1b-users`\n**Context.** Existing users of static-ads, the scraper admin, and the console must sign in at the S1 exit gate without a new password ceremony run by hand.\n**Read first.** S1a routes; static-ads `schema.sql` `users` (email, display_name, is_admin); scraper `meta_ads.admin_users` (email, role admin|user); console `users` table (`RescaleOS/console/src/db/client.ts`, roles in `src/lib/roles.ts`); lock doc \u00a74.1 legacy role mapping.\n**Deliverables.** (1) `core.auth_tokens(id, user_id, kind \u2208 invite|magic|reset, token_hash, expires_at, used_at)`; routes `POST /api/auth/invite` (admin), `POST /api/auth/magic-link`, `POST /api/auth/reset/request`, `POST /api/auth/reset/confirm`, `GET /api/auth/accept?token=` (validates the token and returns `{email, kind, expires_at}` only), `POST /api/auth/accept {token, password}` (sets the password for invite/reset, marks the token used, signs in, sets both cookies). (2) `apps/api/core/scripts/import_users.py`: reads the three user tables (connection strings via env), dedupes by lower(email), creates `core.users` (inactive until accepted) + `workspace_members` with roles: static-ads `is_admin`\u2192`admin`; if `INTELLIGENCE_OWNER_EMAIL` is set and the email is in that allowlist (static-ads derives `can_see_intelligence` from env, `auth.py` L33 \u2014 it is not stored), also add `researcher`; scraper `admin`\u2192`admin`, `user`\u2192`researcher`; console `editor`\u2192`editor`, `creative_strategist`\u2192`strategist` (`RescaleOS/console/src/lib/roles.ts` L4); everyone \u2192 `viewer`. Writes `docs/ops/user-import-<date>.csv` (email, sources, roles, invite sent y/n). (3) Emails go through M3's mailer; until M3 lands, the script prints the accept URLs.\n**Interfaces.** Consumes S1a; M3 for sending.\n**Steps.** Table + routes \u2192 import script against dumps \u2192 dry-run report \u2192 tests.\n**Acceptance.** `pytest apps/api/tests/core/test_users.py` green (invite \u2192 accept \u2192 login; expired token 410; reset flow); `python apps/api/core/scripts/import_users.py --dry-run` on the three real dumps prints N users, 0 duplicate emails, and the CSV; post the CSV summary (counts only).\n**Out of scope.** Sending emails (M3); role editing UI (V11).",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W1"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s1b-users"
      }
    ]
  },
  {
    "key": "S20",
    "title": "SaaS readiness review \u2192 100 %",
    "description": "## S20 \u00b7 SaaS readiness review \u2192 100 % \u00b7 S \u00b7 S \u00b7 S5-W6\n**Context.** The single go/no-go that defines 100 %: every control above verified, every workflow native, nothing pending.\n**Deliverables.** `docs/decisions/S20-saas-readiness-review.md`: checklist over tenancy (S21), credentials/quotas (S22, A22), audit (S23), admin/onboarding (V21), metering/invoicing (S24), hardening (A17), retention/deletion (S25); every workflow in the source map native; standalones retired; company-ops lists still in ClickUp by design.\n**Acceptance.** Review merged with pass on every line and an empty follow-up list \u2014 the definition of delivered.\n**Out of scope.** New scope; anything not already carded.\n\n",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S5-W6"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "V21",
      "A17",
      "S24",
      "S25b"
    ],
    "links": []
  },
  {
    "key": "S21",
    "title": "RLS rollout + tenancy CI for core, production, meta_ads, studio",
    "description": "## S21 \u00b7 RLS rollout + tenancy CI for core, production, meta_ads, studio \u00b7 S \u00b7 L \u00b7 S5-W1\n**Context.** Every table already carries `workspace_id` (S1 rule). S5 turns that into enforced isolation at the database.\n**Read first.** `db/migrations/unified/*` (all schemas), `apps/api/core/auth/deps.py` (how the request's workspace is known), `apps/api/tests/core/test_isolation.py` (S13), Postgres RLS docs; the ecomprofits pattern `has_role_on_account()` in `apps/web/supabase/schemas/03-accounts.sql` as prior art.\n**Deliverables.** Migration `core/0NN_rls.sql` + one per schema: `alter table \u2026 enable row level security`, policies `workspace_isolation` using `current_setting('app.workspace_id')::uuid`; the API sets `set local app.workspace_id` per request/transaction (`apps/api/core/db/session.py`), jobs set it per workspace loop; a `bypass_rls` role only for migrate/backup; `pytest apps/api/tests/security/test_rls.py` (cross-workspace fixtures for all four schemas, direct SQL as the app role \u2192 0 rows); CI required.\n**Acceptance.** Tests green; `select count(*) from production.sessions` as the app role with a foreign workspace id = 0; existing S13 tests still green.\n**Out of scope.** Billing; UI.",
    "labels": [
      "lane:S",
      "size:L",
      "wave:S5-W1"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "D51",
      "D52"
    ],
    "links": []
  },
  {
    "key": "S22",
    "title": "Per-workspace credentials, keys, quotas + admin APIs",
    "description": "## S22 \u00b7 Per-workspace credentials, keys, quotas + admin APIs \u00b7 S \u00b7 L \u00b7 S5-W2\n**Read first.** `core.integration_credentials` (S5a), `core.providers` (V4), `core.spend_guards`; D51.\n**Deliverables.** `core.workspace_quotas(workspace_id, key, limit, window, action \u2208 warn|pause)` for model spend, scraper jobs, runtime sessions, storage GB; provider keys and residential/R2 settings per workspace (encrypted); admin APIs `GET/PUT /api/admin/workspaces/:id/credentials`, `/quotas`; resolver `resolve_credentials(workspace, provider)` used by every caller; `pytest apps/api/tests/core/test_workspace_credentials_and_quotas.py`.\n**Acceptance.** Tests green; a workspace with no key of its own cannot use the platform default unless flagged `inherit_platform_keys`.",
    "labels": [
      "lane:S",
      "wave:S5-W2",
      "size:L"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S21"
    ],
    "links": []
  },
  {
    "key": "S23",
    "title": "Audit log + admin provenance",
    "description": "## S23 \u00b7 Audit log + admin provenance \u00b7 S \u00b7 M \u00b7 S5-W3\n**Context.** Multi-tenant operation needs an unforgeable record of who did what in which workspace; S1\u2013S4 only log locally.\n**Deliverables.** `core.audit_log(id, workspace_id, actor_id, actor_kind \u2208 user|service|system, action, target_type, target_id, evidence jsonb, created_at)`; writers for admin actions, gate decisions, bridge mutations, membership changes, impersonation (if any), and the role lifecycle (`role.created`, `role.updated`, `role.deleted`, `member.role_granted`, `member.role_revoked`) with `evidence` holding the previous and new role definition and the previous and new effective permissions; `GET /api/admin/audit?\u2026`; retention per D51; `pytest apps/api/tests/security/test_audit_log.py` with an end-to-end chain for a role change.\n**Acceptance.** Tests green; the chain artefact attached.\n**Out of scope.** Impersonation features themselves; external SIEM export.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S5-W3"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S21",
      "D52"
    ],
    "links": []
  },
  {
    "key": "S24",
    "title": "Usage metering ledger + invoice export (manual invoicing)",
    "description": "## S24 \u00b7 Usage metering ledger + invoice export \u00b7 S \u00b7 L \u00b7 S5-W4\n**Context.** Manual invoicing is in scope, so usage must be metered in a stable ledger and exported as invoice-ready monthly totals without Stripe self-serve flows.\n**Read first.** `core.usage_events` (V4), scraper/runtime job tables, D51.\n**Deliverables.** `core.usage_ledger` rollups per workspace per day (model spend, scraper jobs, transcriber minutes, runtime sessions, storage GB, bridge ops); `GET /api/admin/usage?workspace&month`; export CSV/JSON per workspace-month for manual invoicing; `pytest apps/api/tests/billing/test_usage_metering.py`. No Stripe.\n**Acceptance.** Tests green; one export for a fixture month attached.\n**Out of scope.** Payment collection, subscription checkout, customer-facing billing portals, plan-pricing automation.",
    "labels": [
      "lane:S",
      "wave:S5-W4",
      "size:L"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S22",
      "A22",
      "D51"
    ],
    "links": []
  },
  {
    "key": "S25a",
    "title": "Workspace export + retention jobs",
    "description": "## S25a \u00b7 Workspace export + retention jobs \u00b7 S \u00b7 M \u00b7 S5-W5\n**Context.** Tenants need their data back on request and stale data must age out by policy before deletion is meaningful.\n**Deliverables.** Workspace export (all schemas + R2 prefix manifest) as a signed download; retention jobs per data class (D51) with audit entries; `pytest apps/api/tests/security/test_export_retention.py`.\n**Acceptance.** Test green; one export bundle for a fixture workspace attached.\n**Out of scope.** Deletion itself (S25b).",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S5-W5"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S21",
      "S23",
      "D51"
    ],
    "links": []
  },
  {
    "key": "S25b",
    "title": "Deletion queue, dry-run, purge across Postgres / R2 / lineage",
    "description": "## S25b \u00b7 Deletion queue, dry-run, purge across Postgres / R2 / lineage \u00b7 S \u00b7 M \u00b7 S5-W6\n**Context.** Deletion must be complete across Postgres, R2 and lineage, approved, and provable \u2014 the last SaaS-hardening control.\n**Deliverables.** Deletion request queue with approval, legal-hold override, dry-run report, purge across every schema + R2 prefixes + lineage nodes, audit entries; `pytest apps/api/tests/security/test_delete_purge.py`.\n**Acceptance.** Test green; one dry-run deletion report attached.\n**Out of scope.** Legal review of retention law; backups purge (handled by retention policy on the Storage Box).",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S5-W6"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S25a"
    ],
    "links": []
  },
  {
    "key": "S26",
    "title": "Board batch 2 API + mirrors: creativeDemand, scripts, creatives",
    "description": "## S26 \u00b7 Board batch 2 API + mirrors: creativeDemand, scripts, creatives \u00b7 S \u00b7 M \u00b7 S4-W4\n**Read first.** D42 (authority, ownership per list), `demand-week.ts`, `spawn.ts`, `forward.ts`, `qa-gates/*`, `fulfillment.ts`; S16 (script bridge \u2014 becomes the native store).\n**Deliverables.** Native stores `workspace.demand_weeks/demand_lines`, `workspace.scripts` (merging `studio.scripts` link), `workspace.creatives`; the cascade logic (parse \u2192 lock \u2192 spawn \u2192 approve \u2192 creative \u2192 launch) runs in workos against the native stores with ClickUp as the mirror (write-through both ways) until the board flips per list; QA gates ported (`M4`); the Creative analyzer (`M5`: video/audio evidence inputs, the 17 CR_* fields auto-filled on Creative entities) ported onto the native Script/Creative approval surfaces; dual-run reconciliation report; `vitest services/workos/tests/board-batch2.spec.ts`.\n**Acceptance.** Test green; one dual-run report (both directions, 0 unexplained diffs); owner sign-off per list.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S4-W4"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S11",
      "D42"
    ],
    "links": []
  },
  {
    "key": "S27",
    "title": "Notification platform port (registry, outbox, dispatcher, destinations, mutes, digests)",
    "description": "## S27 \u00b7 Notification platform port (registry, outbox, dispatcher, destinations, mutes, digests) \u00b7 S \u00b7 L \u00b7 S3-W7\n**Context.** Every job and rule in Skynet needs one outbound path with dedup, routing and mutes \u2014 ecomprofits already has it; Skynet ports the pattern (Python) rather than re-inventing it.\n**Read first.** ecomprofits `packages/features/notification-platform/src/server/{emit,dedup,destinations,mutes,dispatch,deliver,api,cutover}.ts`, `events/registry`, `schemas/index.ts` (severity, EmitEventOptions), `naming-conventions/src/server/naming-violation-event-factory.ts` (an event family with filters + attachment); workos Discord/Telegram senders; X3's alert senders (to be folded in).\n**Deliverables.** `core.notification_events(id, workspace_id, feature, event_type, severity, payload jsonb, dedup_key, final_status, created_at, dispatched_at)` (unique `(workspace_id, dedup_key)`); `core.notification_deliveries(id, event_id, workspace_id, provider, channel_type, destination_fingerprint, destination_metadata jsonb, status \u2208 pending|processing|sent|failed|dead_letter|muted|skipped|retry_scheduled, attempts int default 0, next_attempt_at, locked_at, locked_by, provider_message_id, provider_response jsonb, last_error, created_at, updated_at)`; `core.notification_destinations(workspace_id, channel_type \u2208 discord|telegram|email|webhook, purpose, config_encrypted, is_enabled)`; `core.notification_mutes`; event registry (`apps/api/core/notify/registry.py`, typed payloads per feature: intelligence, studio, production, workspace, admin, spend_guard, synthetic); `emit()` \u2192 outbox; dispatcher job + sweeper with delivery claim / retry / finalize helpers (idempotent on `provider_message_id`); digests (daily ops digest); routing is purpose-based (no subscription UI yet): one enabled destination per `(workspace_id, purpose, channel_type)` with deterministic fallback rules in `docs/contracts/notifications-routing.md`; message renderers per channel with quick-action buttons (Discord components / Telegram inline keyboards) that call Skynet actions through the same permission checks (A24 wires the handlers); initial senders migrated onto `emit()`: X3, A13b, S9, V4 and every new S3 job; `pytest apps/api/tests/core/test_notifications.py`.\n**Acceptance.** Test green (dedup, mute, routing by purpose, digest); on staging one event of each feature reaches Discord and Telegram; no inline `requests.post` to a chat API remains (`rg` = 0).\n**Out of scope.** Chat ingestion (A23); the agent (A24).",
    "labels": [
      "lane:S",
      "size:L",
      "wave:S3-W7"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "A21",
      "S16"
    ],
    "links": []
  },
  {
    "key": "S3",
    "title": "Creative-code service",
    "description": "## S3 \u00b7 Creative-code service \u00b7 M \u00b7 contract-wave schema first \u00b7 `feat/s/s3-creative-codes`\n**Context.** The creative code is the wire that closes the loop: Production stamps it, the ad name carries it, ecomprofits parses it back. There is exactly one generator today, in workos. Skynet must produce byte-identical codes and must draw `seq` from the same counter, or codes collide.\n**Read first.** Lock doc \u00a74.2; workos `service/src/modules/naming/creative-code.ts` (`buildCreativeCode(parts)` L84, `variantLetter`, `kindFromFormat`, `isTeamMade`, `teamMadeFromCode`), `sequence.ts` (`nextSequence(db, productNumber, codeType)` L18 \u2192 `select rescale_service.next_naming_seq($1,$2)`), `migrations/001_sequence_counter.sql`; workos `assemble.ts` (`assembleAdName`, `AD_REQUIRED` L93\u2013109) and `__fixtures__/naming-conventions.ts` (`META_AD_V3`); ecomprofits `packages/features/naming-conventions/src/lib/parser.ts` (`parseNameWithRules(sourceName, rules)` L18) and the live rule set for the Rescale Meta account (export it with `select rules from naming_conventions where account_id='48810a93-baae-4bc3-b562-5cf8814f0edf' and integration_type='meta' and entity_level='ad' and is_active;` and save it as `packages/contracts/creative-code/ecomprofits-meta-rules.json`).\n**Deliverables.** (1) Migration `db/migrations/unified/core/002_creative_codes.sql`: `core.creative_codes(code text, workspace_id uuid, product_number text, kind text check (kind in ('RC','TM')), team_made bool, seq int, variant text, asset_id uuid null, asset_key text null, asset_type text, session_id uuid null, issued_at timestamptz, issued_by uuid, naming_version text default 'v3', unique (workspace_id, code))`. (2) `apps/api/core/creative_codes/generator.py`: a line-for-line port of `buildCreativeCode` + `variantLetter` + `kindFromFormat` + `isTeamMade` (same errors). (3) `sequence.py`: `next_seq(product_number, kind)` executing `select rescale_service.next_naming_seq(%s,%s)` on the ecomprofits connection with the `rescale_service` role (env `ECOMPROFITS_WORKOS_URL`), inside the same transaction as the insert \u2014 no local counter, ever. (4) Routes: `POST /api/creative-codes {product_number, kind, team_made, variant?='A', asset_type, session_id?}` \u2192 `{code, seq}`; `GET /api/creative-codes/:code`; `POST /api/creative-codes/:code/iterate` \u2192 next variant letter, same seq. (5) `packages/contracts/creative-code/`: `schema.json`, `normalize.py`/`normalize.ts` (`normalize_creative_code`: upper-case \u2192 `RCi`/`TMi` casing restored, `\\s*(hook|_HK)\\s*(\\d)` \u2192 ` hook N`, trim), fixtures `live-samples.json` (\u226530 real `nc_creative_code` values from `meta_ads_dashboard_view`, including `RCI31-A_HK 1`), and `cross-check.json` (1,000 random `CreativeCodeParts` + the TS output). Add the helper script `service/scripts/dump-creative-codes.ts` in the workos repo **in this card** (it does not exist yet) to generate that fixture from `service/src/modules/naming/creative-code.ts`; after A1a the same file lives at `services/workos/scripts/dump-creative-codes.ts`.\n**Interfaces.** Exposes the routes + the normaliser. Consumed by V7 (stamp), S7 (readback key), S8 (launch task field).\n**Steps.** Contract wave: schema + fixtures merged first. Then generator port + cross-check test \u2192 sequence via ecomprofits \u2192 routes \u2192 parser round-trip test.\n**Acceptance.** `pytest apps/api/tests/core/test_creative_codes.py` green: (a) 1,000/1,000 cross-check parts produce the TS string; (b) 20 issued codes placed into a v3 ad name (fixture `META_AD_V3` layout) parse with the exported ecomprofits rules and `creative_code` round-trips; (c) two concurrent issues for the same product get distinct `seq` (test against a scratch `rescale_service` schema); (d) normaliser maps every live sample to canonical form. Post the test output.\n**Out of scope.** Ad-name assembly (workos endpoint, S8); RescaleOS ad names (A12, S3); changing the grammar.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W2"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S1a",
      "A1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s3-creative-codes"
      }
    ]
  },
  {
    "key": "S4",
    "title": "Lineage + outbox (minimal)",
    "description": "## S4 \u00b7 Lineage + outbox (minimal) \u00b7 S \u00b7 `feat/s/s4-lineage-outbox`\n**Context.** The S1 exit gate must show the chain session \u2192 concept \u2192 asset \u2192 export \u2192 code \u2192 performance for one real ad. Only Production (V7) and readback (S7) write to it in S1.\n**Read first.** Lock doc \u00a74 (contract 3); technical plan \u00a72.4.3.\n**Deliverables.** Migration `003_lineage.sql`: `core.lineage_nodes(id uuid pk, workspace_id, type text check (type in ('session','concept','asset','export','creative_code','launch','performance','winner')), ref_id text, attrs jsonb, created_at)`, `core.lineage_edges(from_id, to_id, relation text, pk(from_id,to_id))`, `core.events_outbox(id bigserial, workspace_id, type, payload jsonb, created_at, processed_at null)`. Module `apps/api/core/lineage/`: `POST /api/lineage/nodes` (batch: nodes + edges, idempotent on `(workspace_id,type,ref_id)`), `GET /api/lineage/chain?type=creative_code&ref=<code>` \u2192 ordered chain (both directions, depth \u2264 8), poller skeleton `outbox.py` (marks processed, no consumers yet). `packages/contracts/lineage/schema.json` + fixture chain.\n**Interfaces.** Consumed by V7 (writer), S7 (writer), V10 (reader).\n**Acceptance.** `pytest apps/api/tests/core/test_lineage.py`: insert the fixture chain \u2192 `GET /chain?type=creative_code&ref=TMi2-A` returns session\u2192concept\u2192asset\u2192export\u2192creative_code\u2192performance in order; re-posting the same batch creates no duplicates.\n**Out of scope.** Scraper/studio/workos producers, inbox, coverage store (S14/S15, S3).",
    "labels": [
      "lane:S",
      "size:S",
      "wave:W2"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s4-lineage-outbox"
      }
    ]
  },
  {
    "key": "S4b",
    "title": "Bindings/spend-guard contract artifact",
    "description": "## S4b \u00b7 Bindings / spend-guard contract artifact \u00b7 S \u00b7 `feat/s/s4b-bindings-contract`\n**Context.** Vince builds V4 (tables + evaluation) and every sidecar will post usage. The contract must exist before the code so consumers build against fixtures.\n**Read first.** Lock doc \u00a74 contract 4; technical plan \u00a72.4.4; V4 spec below (tables).\n**Deliverables.** `packages/contracts/bindings/`: `binding.schema.json` (`GET /api/bindings/:workflow` response: `{workflow_key, provider:{kind, base_url}, model:{slug, modality}, config:{\u2026}, paused:bool, guard:{window_hours, warn_usd, pause_usd, spent_usd}}`), `usage-event.schema.json` (`POST /api/usage`: `{workspace_id, service, workflow_key, model, tokens_in, tokens_out, cost_usd, request_id}`), `spend-guard-states.md` (`ok \u2192 warn \u2192 paused \u2192 resumed`, who can resume), fixtures (one per state), consumer tests: Python (`apps/api`), Node (`services/scraper`, `services/agent-runtime` \u2014 a 10-line client each that validates against the schema).\n**Acceptance.** `pnpm --filter contracts test` green; the three consumer tests import the fixtures and pass.\n**Out of scope.** The implementation (V4), the admin UI (V5).",
    "labels": [
      "lane:S",
      "size:S",
      "wave:W2"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s4b-bindings-contract"
      }
    ]
  },
  {
    "key": "S5a",
    "title": "Ecomprofits read layer",
    "description": "## S5a \u00b7 Ecomprofits read layer \u00b7 M \u00b7 `feat/s/s5a-ecomprofits-read`\n**Context.** Performance comes back from ecomprofits by creative code. If the columns or formulas are wrong, the \"closed loop\" is fake. All facts below are verified against the ecomprofits schema.\n**Read first.** Lock doc \u00a74.3 (contract); ecomprofits `apps/web/supabase/schemas/27-meta-ads.sql` (`meta_ad_insights` L383\u2013484, `meta_intraday_ad_insights` L620, `combined_meta_ad_insights` L919, `meta_ads.parsed_naming` L211\u2013244), `40-dashboard-views.sql` (readonly grant loop L304\u2013312, role list at L309; klaviyo loop L1514\u20131522, L1519), `39-ads-performance-unified.sql`; `packages/features/meta-ads-sync/src/lib/business-logic.ts` L257\u2013297 (`calculateWinnerLoserStatus`). For the readonly-role pattern, copy the existing `claude_readonly` grant style already present in `40-dashboard-views.sql` and `27b-meta-ads-breakdowns.sql`; do not look for a separate credentials doc.\n**Deliverables.**\n0. Migration `db/migrations/unified/core/004_integration_credentials.sql`: `core.integration_credentials(workspace_id uuid not null, provider text not null, account_id text not null, credential_json jsonb not null, created_at timestamptz default now(), updated_at timestamptz default now(), primary key (workspace_id, provider))`. For ecomprofits store `provider='ecomprofits'`, `account_id` = the ecomprofits account id, and `credential_json` keys `readonly_url` and `meta_account_id`.\n1. Role `skynet_readonly` on ecomprofits Postgres (created out-of-band like `claude_readonly`; password to the vault) + ecomprofits PR: add `'skynet_readonly'` to the `rolname in (...)` lists at `40-dashboard-views.sql` L309 and L1519 and to `27b-meta-ads-breakdowns.sql` loops; new declarative file `apps/web/supabase/schemas/44-skynet-read.sql` with the view below + `grant select \u2026 to skynet_readonly`; migration via `pnpm --filter web run supabase:db:diff -f skynet_read`.\n2. View `public.skynet_creative_daily` (security_invoker): from `combined_meta_ad_insights i join meta_ads a on (a.meta_account_id, a.ad_id) = (i.meta_account_id, i.ad_id)`; columns `account_id, ad_account_id, ad_id, date, creative_code_raw = a.parsed_naming->>'creative_code', product_number = a.parsed_naming->>'product_number', market = a.parsed_naming->>'market', spend_native = amount_spent, spend_eur = amount_spent_eur, impressions, reach, three_second = video_plays, thruplay = video_thruplay_watched, link_clicks, purchases, purchase_value_eur, cost_per_purchase_eur, cpp_meta = cpp, breakeven_roas, seven_day_spend_eur, seven_day_purchases, seven_day_purchase_value_eur, seven_day_roas, seven_day_eligible, winner_loser_status, creative_hit, updated_at`.\n3. `apps/api/core/readlayer/`: `client.py` (connection from `core.integration_credentials` where `provider='ecomprofits'`: `credential_json->>'readonly_url'`, scoping id = `account_id`), `queries.py` (`creative_daily(account_id, codes[], date_from, date_to)` \u2014 **always** `where account_id = %s`), `metrics.py` (`hook_rate = three_second/impressions`, `hold_rate = thruplay/three_second`, `roas = purchase_value_eur/spend_eur`, `cost_per_purchase_eur = spend_eur/purchases`; `None` on zero), `normalize` from S3 applied to `creative_code_raw` \u2192 `creative_code`.\n4. `packages/contracts/metrics.md` (formulas, column provenance, currency rule: EUR reporting, native for display).\n**Interfaces.** Consumed by S7 (readback), V10 (winners view).\n**Steps.** Role + grant PR (ecomprofits, Sead owns) \u2192 view \u2192 client/queries \u2192 fixtures from a frozen sample (3 ads \u00d7 7 days, exported to `apps/api/tests/fixtures/ecomprofits_sample.json`) \u2192 staging read of real data.\n**Acceptance.** `pytest apps/api/tests/core/test_readlayer.py` green on the fixture; PR description contains a table: 3 known ads \u00d7 1 day with hook/hold/ROAS/cost-per-purchase from this module beside the same numbers from the ecomprofits Meta dashboard (`meta-ads.tsx` holdPct, `meta-master.tsx` hookRate) \u2014 equal to 2 decimals; a query without `account_id` fails a unit test.\n**Out of scope.** Winner/loser evaluation (S5b/S9, S3); Pinterest/Taboola; writes.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W3"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S1a",
      "S3"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s5a-ecomprofits-read"
      }
    ]
  },
  {
    "key": "S5b",
    "title": "Winner/loser rule evaluation (workos classify knobs)",
    "description": "## S5b \u00b7 Winner/loser rule evaluation \u00b7 S \u00b7 M \u00b7 S3-W6\n**Read first.** workos `platform-sync/classify.ts` (zombie \u2264 BEROAS\u00d71.115; winner \u2265 target ROAS 15 % and spend \u2265 5\u00d7 target CPP; super-winner + 7-d spend \u2265 \u20ac1 000; TOF review zone), `thresholds.ts` (Google Sheet knobs); ecomprofits `business-logic.ts`; `core.performance_snapshots`.\n**Deliverables.** `core.classify_rules(workspace_id, product_id?, knobs jsonb, version)` (knobs migrated from the sheet, editable in admin); evaluator over snapshots \u2192 `core.winner_proposals(creative_code, verdict, evidence, rule_version)`; fixture parity vs workos thresholds.\n**Acceptance.** `pytest apps/api/tests/studio/test_winner_rules.py` green with the parity fixture.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S3-W6"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S15"
    ],
    "links": []
  },
  {
    "key": "S6",
    "title": "Discovery write-up \u2192 G2 (launch board first; board order)",
    "description": "## S6 \u00b7 Discovery write-up \u2192 Gate G2 \u00b7 S \u00b7 S \u00b7 S4-W1\n**Context.** Decides whether the launch board is the first native board.\n**Read first.** S0b doc; lock doc \u00a73 (G2 exit + fail path); technical plan \u00a74.1.\n**Deliverables.** `docs/decisions/G2-workspace-board-order.md`: decision (launch board first \u2014 go / re-target / defer), pilot product, media buyer, the S8b field list derived from the discovery table (which of the 23 Launch fields the app shows/writes), and the ordered list of the remaining 12 creative-workflow boards from D42. Gate review with Agon (+ Vince).\n**Acceptance.** ADR merged before S8b starts; S8b's PR cites it.\n**Out of scope.** Anything after G2.",
    "labels": [
      "lane:S",
      "size:S",
      "wave:S4-W1"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S0b",
      "D41",
      "D42"
    ],
    "links": []
  },
  {
    "key": "S7",
    "title": "Readback job v0",
    "description": "## S7 \u00b7 Readback job v0 \u00b7 M \u00b7 `feat/s/s7-readback`\n**Context.** Every 15 minutes, performance per creative code lands in Skynet and a lineage node is written \u2014 the last hop of the loop.\n**Read first.** S5a module; S4 lineage API; S3 normaliser; lock doc \u00a74.3 snapshot table.\n**Deliverables.** Migration `005_performance_snapshots.sql`: `core.performance_snapshots(workspace_id, creative_code, ad_id, ad_account_id, date, spend_native, spend_eur, impressions, reach, three_second, thruplay, link_clicks, purchases, purchase_value_eur, cost_per_purchase_eur, hook_rate, hold_rate, roas, breakeven_roas, seven_day_spend_eur, seven_day_purchases, seven_day_purchase_value_eur, seven_day_roas, seven_day_eligible, winner_loser_status, creative_hit, source_synced_at, snapshot_at, unique (workspace_id, creative_code, ad_id, date))`. Job `apps/api/core/readback/job.py`: for each workspace with ecomprofits credentials \u2192 codes issued in the active readback window (`READBACK_WINDOW_DAYS`, default 90) (`core.creative_codes`) \u2192 `creative_daily(account_id, codes, last 14 days)` \u2192 upsert snapshots \u2192 one `performance` lineage node per (code, date) linked to the `creative_code` node. Scheduler: `apps/api/core/scheduler.py` (APScheduler or a `while` loop in the existing `scrape_worker` process \u2014 reuse `supervisord` program), interval `READBACK_INTERVAL_MIN=15`; `GET /api/performance?code=` for V10.\n**Acceptance.** `pytest apps/api/tests/core/test_readback.py` on the S5a fixture (upsert idempotent; lineage node created once); on staging a stamped test creative's metrics appear in `core.performance_snapshots` within one cycle \u2014 post the row and the chain JSON.\n**Out of scope.** Winner evaluation, alerts.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W4"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S5a",
      "S3",
      "S4"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s7-readback"
      }
    ]
  },
  {
    "key": "S8a",
    "title": "Workos bridge endpoints: ad-name assembly + attach export package to the Launch card",
    "description": "## S8a \u00b7 Narrow workos bridge: ad-name assembly + attach export package \u00b7 M \u00b7 W3 \u00b7 old workos repo, branch `feat/skynet-bridge-s1`, deployed to Railway (S1-rev)\n**Context.** S1 needs two things from workos: the assembled v3 ad name for an export, and the export package attached to the **already-existing** Launch card. No launch list/read/create/update API \u2014 that is S8b (S4).\n**Read first.** Lock doc \u00a74.5 (human-owned vs machine-owned fields); workos `service/src/app.ts`, `index.ts` (route mounting before `serve`), `modules/cascade/webhooks.ts` (`registerWebhookRoute` shape), `modules/naming/assemble.ts` (`assembleAdName`, `AD_REQUIRED`), `clients/clickup.ts` (`resolveField`, `setCustomField`, `getTask`, comments), `config.ts` (`EnvSchema`), test pattern `apply-names.test.ts`.\n**Deliverables.** `service/src/modules/skynet-bridge/` with `registerSkynetBridgeRoutes(app, deps)` mounted in `index.ts`; auth = Skynet service token (RS256, `typ=service`, verified with `SKYNET_JWT_PUBLIC_KEY_PEM`, `jose`). Endpoints: `POST /naming/ad-name` (`AdNameInput` \u2192 `assembleAdName` \u2192 `{ad_name}` or `{withheld_reason, missing[]}`); `POST /launches/:id/package` (body `{creative_code, ad_name, assets:[{url,kind,filename}], manifest_url, source:\"skynet\"}`; verifies the Launch task exists and its linked Creative's code equals `creative_code`; writes only `Creative URL` (durable app URL) if empty, posts one comment \"Skynet export package\" with the asset links + manifest, adds the `skynet-package` tag; never touches status, assignee or any machine-owned field; idempotent on `(task_id, creative_code)` via a comment marker). Config: `SKYNET_JWT_PUBLIC_KEY_PEM`, `SKYNET_BRIDGE_ENABLED` (default false). Fixtures in `service/src/modules/skynet-bridge/fixtures/` (copied into monorepo `packages/contracts/launch/` by V7).\n**Acceptance.** `pnpm vitest run src/modules/skynet-bridge` green (mocked ClickUp: ad name assembled for a complete input, withheld with `missing[]` for an incomplete one; package attached once, second call no-op; wrong code \u2192 409; bad token \u2192 401); on a sandbox Launch task: curl both endpoints, screenshot the comment + `Creative URL`. Deployed to Railway with the flag on.\n**Out of scope.** Creating launches; status changes; the native board (S8b/V9, S4).",
    "labels": [
      "lane:S",
      "size:M",
      "wave:W3"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S1a",
      "S3"
    ],
    "links": []
  },
  {
    "key": "S8b",
    "title": "Launch board API + ClickUp mirror",
    "description": "## S8b \u00b7 Launch board API + ClickUp mirror \u00b7 S \u00b7 L \u00b7 S4-W2\n**Read first.** S8a module (extend); lock \u00a74.5 (status classes, field ownership, per-field compare-and-write, durable URLs); `forward.ts:550`, `LISTS.launches`, `FIELDS.launches`, `reconcile-cron.ts`, `launch-outcome.ts`, `clients/clickup.ts`; D41, D42.\n**Deliverables.** Full launch API in workos (`GET /launches`, `GET /launches/:id`, `POST /launches` with Creative + Product relationships, `PATCH /launches/:id` per-field compare-and-write with `conflicts[]`, outcome statuses observe-only \u2192 409), idempotency table `rescale_service.launch_writes`, webhook \u2192 Skynet on every launch change, native store `workspace.launches` in Skynet as a **mirror** (ClickUp still authoritative until the board flips), reconciliation job both ways with a report; `LAUNCH_LIST_ID` sandbox; `vitest services/workos/tests/launch-board-mirror.spec.ts`.\n**Acceptance.** Test green; bidirectional create/update transcript; 24-h reconciliation report on staging = 0 diffs.",
    "labels": [
      "lane:S",
      "size:L",
      "wave:S4-W2"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S6"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/s/s8-launch-board-api"
      }
    ]
  },
  {
    "key": "S9",
    "title": "Winner/loser human gate + winner lineage",
    "description": "## S9 \u00b7 Winner/loser human gate + winner lineage \u00b7 S \u00b7 M \u00b7 S3-W6\n**Deliverables.** Proposals (winner **and loser**) surface in the inbox (S14) and the winners view; confirm/override with reason \u2192 `winner` or `dead_angle` lineage node + event, the loser carrying the rule it taught (S15); resolved status exposed to Studio (scoreboard) and Production (winners gallery); the workos platform-sync flag stays as a mirror until S4.\n**Acceptance.** `pytest apps/api/tests/studio/test_winner_gate.py` green; one confirmed winner emits the lineage event; one confirmed loser emits a `dead_angle` lineage event with reason + `rule_taught`; the API shows both.",
    "labels": [
      "lane:S",
      "size:M",
      "wave:S3-W6"
    ],
    "assignee_username": "sead",
    "depends_on_keys": [
      "S5b",
      "S15"
    ],
    "links": []
  },
  {
    "key": "V1",
    "title": "SSO in production API/UI",
    "description": "## V1 \u00b7 SSO in production API/UI \u00b7 S \u00b7 `feat/v/v1-sso`\n**Context.** One cookie must open Production. Today it mints its own HS256 JWT and the frontend keeps it in localStorage and appends `?token=` to 22 image/SSE/download URLs. Rewriting those is not S1 work, so S1 keeps the token paths and swaps the token.\n**Read first.** Lock doc \u00a74.1 incl. \"static-ads compatibility bridge\"; `app/backend/auth.py` L48\u201388; `app/backend/main.py` L75\u201378; `app/frontend/src/lib/api.ts` L6\u201359 and the `?token=` lines; `packages/contracts/auth/verify.py` + samples (S1a).\n**Deliverables.** (1) `auth.py`: `get_current_user` decodes with `verify_access_token` from the contracts package (cookie `rs_access` \u2192 Bearer \u2192 `?token=`, in that order); map claims \u2192 the existing user dict: `email`, `is_admin = 'admin' in roles`, `can_see_intelligence = {'researcher','admin'} & roles`, `token_version = tv`; look up `users` by email; if missing, create the row with `email`, `display_name = claim.name`, `is_admin` from roles, `token_version = tv`, and `password_hash` = a bcrypt hash of a random 32-byte secret (`users.password_hash` is NOT NULL) so legacy password login stays unusable when `LEGACY_LOGIN_DISABLED=1`. (2) Remove `create_token` usage from the API path and disable the legacy auth routes that actually exist today (`app/backend/routes/auth.py`, mounted under `/api/auth`): `POST /api/auth/login`, `PUT /api/auth/me/password`, `PUT /api/auth/users/{user_id}/password`. Keep `GET /api/auth/me`. Leave the `/api/auth/users*` admin-management routes in place until V11 replaces them. `LEGACY_LOGIN_DISABLED=1` returns 410 on each disabled route until cutover cleanup. (3) `lib/api.ts`: `getToken()` reads `localStorage.auth_token` as today; add `refreshSessionToken()` = `GET /api/auth/session-token` \u2192 `setToken()`, called on app mount (`main.tsx`) and once on any 401. V2a owns the shell login UI and route wiring.\n**Interfaces.** Consumes S1a. Exposes nothing new.\n**Steps.** Verifier swap \u2192 route disabling behind flag \u2192 frontend token refresh \u2192 tests.\n**Acceptance.** `pytest app/backend/tests/test_auth_sso.py` green (valid \u2192 200; expired / wrong-kid / revoked-tv \u2192 401; admin route with viewer token \u2192 403); manual on local stack: with a valid `rs_access` cookie present, open a Production page and an `<img src=\u2026?token=\u2026>` \u2014 both load; after `POST /api/auth/logout`, both 401 within 60 s (screenshots or curl transcript).\n**Out of scope.** Removing `?token=` (V21, later slice); users/roles UI (V11, later slice).",
    "labels": [
      "lane:V",
      "size:S",
      "wave:W1"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v1-sso"
      }
    ]
  },
  {
    "key": "V10",
    "title": "Production polish under shell",
    "description": "## V10 \u00b7 Production polish under shell \u00b7 M \u00b7 `feat/v/v10-production-polish`\n**Read first.** V6, V7, S4 chain API, S7 `GET /api/performance?code=`; Appendix A.2 of the technical plan (the parity rows); pages: `WinnersGallery.tsx`, `ProductCreatives.tsx`, `SessionView.tsx`, storyboard pages, `CanvaPolishedLightbox.tsx`, localize pages, `RecreateModal.tsx`.\n**Deliverables.** Winners gallery + product creatives show the creative code and a \"Lineage\" drawer (chain from S4) and the latest snapshot (S7) when present; regression pass over Explore, Expand, Storyboard (scenes/clips/narration/manifest/delivery), Canva polish + callback, Localize, bulk recreate, labs; dead nav removed; error + loading states on every `/production/*` route; `docs/checklists/production-parity.md` with these rows explicitly: Explore create/open/regenerate/download; Expand create/open/regenerate/download; Storyboard scenes/clips/narration/render/download; Canva polish callback/download; Localize create/stream/output/download; bulk recreate; labs entry pages; winners gallery + product creatives (code + lineage drawer). Each row has columns owner, expected result, actual result, screenshot link.\n**Acceptance.** Parity checklist all green with screenshots, run on staging in the W5 parity pass.\n**Out of scope.** Lighthouse/UX polish (V21, later slice).",
    "labels": [
      "lane:V",
      "size:M",
      "wave:W4"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V6",
      "V7",
      "S4",
      "S7"
    ],
    "links": []
  },
  {
    "key": "V13",
    "title": "Contract/consumer tests + production module docs",
    "description": "## V13 \u00b7 Contract/consumer tests + Production module docs \u00b7 S \u00b7 W5 (S1-rev)\n**Deliverables.** `apps/api/tests/contracts/` consumer tests for auth samples, bindings fixtures, creative-code fixtures, lineage fixture, the S8a launch-package fixtures (copied into `packages/contracts/launch/` by V7); CI job `contracts-consumers` required; `apps/api/production/README.md` + `apps/web/src/production/README.md`.\n**Acceptance.** CI job green on `main`; READMEs merged.",
    "labels": [
      "lane:V",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S1a",
      "S3",
      "S4",
      "S4b"
    ],
    "links": []
  },
  {
    "key": "V14",
    "title": "Prompt layers + per-user overlays + Mathew-editable prompt assets",
    "description": "## V14 \u00b7 Prompt layers + per-user overlays + Mathew-editable assets \u00b7 V \u00b7 L \u00b7 S3-W3\n**Read first.** Functional design \u00a73.0 (layers: doctrine \u2192 workflow \u2192 brand/product \u2192 personal overlay), TrenchOS digest (authoring guardrails), D32; A12's `core.prompt_assets` stub.\n**Deliverables.** `core.prompt_assets(id, workspace_id, layer \u2208 doctrine|workflow|brand|user, key, version, body, author_id, status)` + resolver (`resolve_stack(workflow_key, product, user)` \u2192 ordered layers, recorded on every run as `resolved_stack`); editor UI with versions/diff/publish, negation lint and positive-only checks, **and an eval step before publish** (a benchmark set per layer \u2014 5\u201320 fixed inputs with expected properties, run against the new version and the current one, results side by side; the pattern of Anthropic's `skill-creator`), role-gated (Mathew/admin for doctrine + workflow; users for their overlay); static-ads `instructions` migrated as the first user overlays; `evidence-contract` (evidence classes + claims policy) seeded as a doctrine-layer asset.\n**Acceptance.** `pnpm --filter web test -- studio-prompt-layers`; one recorded edit \u2192 re-run shows the new version in `resolved_stack`.\n**Bridge impact.** Seeds this layer system with the Grok extraction schema, the \"write one for me\" packet contract, and `rules.md` from bridge I1/I3/I4.",
    "labels": [
      "lane:V",
      "wave:S3-W3",
      "size:L"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "A11",
      "D32"
    ],
    "links": []
  },
  {
    "key": "V15",
    "title": "Intelligence native pages, group 2 (research, teardowns, market intel/VOC, tracked-ad Expand / Create script / Recreate, promotion)",
    "description": "## V15 \u00b7 Intelligence native pages, group 2 + tracked-ad actions \u00b7 V \u00b7 L \u00b7 S2-W7\n**Read first.** A12, A14, A15, A18, A18b APIs; old pages `/research*`, `/breakdowns`, `/market-intel/*`.\n**Deliverables.** Research (keywords, runs, ranked products with reasons, Promote to product), Breakdowns (list, detail, brand fan-out), Market intel (scopes, documents, threads, aliases, stats; VOC groups + export; evidence view with `pubmed` source filter, lens + evidence-class filters, export), tracked-ad actions on any ad/family: Expand (Production), Create script, Recreate; static-ads' scraped-ads pages retired (collections become tags on the one corpus).\n**Acceptance.** `pnpm --filter web test -- intelligence-group2`; staging walkthrough research \u2192 product \u2192 tracked ad \u2192 Expand / script (recording).",
    "labels": [
      "lane:V",
      "size:L",
      "wave:S2-W7"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V22",
      "A12b",
      "A14",
      "A15",
      "A18",
      "A18b"
    ],
    "links": []
  },
  {
    "key": "V16",
    "title": "Hook session + approval gates",
    "description": "## V16 \u00b7 Hook session + approval gates \u00b7 V \u00b7 L \u00b7 S3-W5\n**Read first.** Functional design \u00a73.4 (hook gate), Hook Engine digest (seed content, claim validation, brand-tone band), D32; V14 resolver; readback hook rate (W12).\n**Deliverables.** Hook session flow: over-produce (N hooks per brief) \u2192 cull (brand rules, claim validation, stop-scroll judge) \u2192 survivors \u2192 strategist picks keepers / kills with reasons (recorded as signals for V17); gates per asset or batch per D32, with owners and evidence; hook rate shown from readback per launched hook; `studio.hook_sessions`, `studio.hook_candidates`.\n**Acceptance.** `pnpm --filter web test -- hook-session`; a saved session artefact with generated / survivors / kill reasons.",
    "labels": [
      "lane:V",
      "size:L",
      "wave:S3-W5"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V14",
      "D32"
    ],
    "links": []
  },
  {
    "key": "V17",
    "title": "Instinct loop for hooks",
    "description": "## V17 \u00b7 Instinct loop for hooks \u00b7 V \u00b7 M \u00b7 S3-W7\n**Read first.** Instinct Transfer digest, functional design \u00a73.4 (instinct loop rules); V16 signals.\n**Deliverables.** Per strategist \u00d7 deliverable type: exemplar pool (shipped or human-rated only), two-tier rules (hard \u2192 auto-reject; tendency with context), before/after pairs, a judge scoring drafts before review; captures are proposed from edits/picks/kills and ratified by the user; repeated signals surface as proposals (second occurrence = pattern/tendency proposal; third occurrence = reusable rule/workflow proposal); two explicit ritual moments in the UI \u2014 end-of-session **reflect** (\"what is worth keeping, and where: rule / tendency / exemplar / dead angle\") and mid-session **remember this** (a standing rule stated in chat or review becomes a proposed capture) \u2014 per the accelerator skills; rules apply to the author's drafts after their review, team-wide only after approval; rule audit view.\n**Acceptance.** `pnpm --filter web test -- instinct-hooks`; one ratified rule capture attached to a hook review.\n**Bridge impact.** Miguel's reaction-banking rule from bridge I4 is seed content for the instinct captures and ritual moments here.",
    "labels": [
      "lane:V",
      "size:M",
      "wave:S3-W7"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V16",
      "S9"
    ],
    "links": []
  },
  {
    "key": "V18",
    "title": "Coverage map + gap finder + allocation + brief queue",
    "description": "## V18 \u00b7 Coverage map + gap finder + allocation + brief queue \u00b7 V \u00b7 L \u00b7 S3-W6\n**Read first.** Functional design \u00a73.3 (Mathew's system map), D32; S15 coverage facts.\n**Deliverables.** Coverage map per product (segments \u00d7 awareness \u00d7 angles) with the evidence-count honesty guard (cells show counts + confidence, never a bare heat colour); gap finder ranked by importance \u00d7 reach; allocation view where the team lead sets net-new / variations / scaling (human gate, pre-filled from last batch's results and the gap ranking) \u2192 brief queue (`studio.briefs`) \u2014 each brief packet carries **swipes from a curated swipe library** (`studio.swipes(id, workspace_id, mechanism, source_ref, media_ref, one_thing_to_steal, added_by, visibility \u2208 personal|shared, approved_by, shared_at)`, organised by mechanism, not by brand \u2014 the accelerator convention; personal swipes stay personal until explicitly promoted to `shared`) next to its teardown / winner / market-intel / coverage evidence \u2192 Script Studio; each brief packet carries market-intel corpus evidence (customer phrases), teardown evidence, winner evidence and coverage evidence, co-selectable, and the review screens show those packets explicitly; rejected proposals persist with reason codes; weekly proposal batch generator (review, not auto-run).\n**Acceptance.** `pnpm --filter web test -- coverage-map`; one reviewed allocation batch linked to brief cards.",
    "labels": [
      "lane:V",
      "size:L",
      "wave:S3-W6"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S15",
      "D32"
    ],
    "links": []
  },
  {
    "key": "V19",
    "title": "Instinct loop for scripts + judge + hypothesis ledger",
    "description": "## V19 \u00b7 Instinct loop for scripts + judge + hypothesis ledger \u00b7 V \u00b7 M \u00b7 S3-W7\n**Deliverables.** Same loop for script drafts, with the same reflect / remember-this ritual moments; the judge on drafts; `studio.hypotheses(brief_id, statement, expected_signal, outcome, evidence)` ledger linked to briefs, sessions and results; the third occurrence of a repeated move proposes a reusable workflow/skill (save-as-skill, with approval).\n**Acceptance.** `pnpm --filter web test -- instinct-scripts`; one draft shows judge output + a linked hypothesis.",
    "labels": [
      "lane:V",
      "size:M",
      "wave:S3-W7"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V23",
      "S9"
    ],
    "links": []
  },
  {
    "key": "V20",
    "title": "Layer-authoring assistants, template sync, video recipes",
    "description": "## V20 \u00b7 Layer-authoring assistants, template sync, video recipes \u00b7 V \u00b7 M \u00b7 S3-W8\n**Read first.** TrenchOS digest (layer-authoring assistants, template fork + upstream sync, provider adapters as data), functional design \u00a73.5 (recipes).\n**Deliverables.** Assistants that help write a layer with the guardrails (negation lint, context-block authority); templates with fork + upstream sync, each template/skill change run through the V14 eval step before publish; `production.recipes` registry (statics + video) over code-backed executors, statics-first graduation to video recipes.\n**Acceptance.** `pnpm --filter web test -- studio-assistants`; one synced template + one video recipe on staging.",
    "labels": [
      "lane:V",
      "size:M",
      "wave:S3-W8"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V14",
      "V23"
    ],
    "links": []
  },
  {
    "key": "V21",
    "title": "Admin / users / roles parity + invite-only onboarding + workspace creation",
    "description": "## V21 \u00b7 Admin / users / roles parity + invite-only onboarding + workspace creation \u00b7 V \u00b7 L \u00b7 S5-W3\n**Read first.** D52; V5 admin; S1b invites; S22/S23 APIs.\n**Deliverables.** Admin area: workspaces (create \u2014 Rescale admin only \u2014 with name, slug, plan notes); members screen with **multi-role assignment**; **role editor** (admin only): system roles read-only, custom roles per workspace \u2014 create / clone a system role / edit permissions / delete (blocked while assigned); effective-permissions drawer per member; no direct per-user permission editing; invites assign one or more roles at invite time; credentials + quotas per workspace (masked), audit viewer, invoice exports (S24). Onboarding: invite \u2192 accept \u2192 first login \u2192 workspace switcher. `pnpm --filter web test -- admin-users-roles-onboarding`.\n**Acceptance.** Walkthrough: create workspace \u2192 invite user \u2192 assign roles \u2192 user sees only their workspace; recording attached.",
    "labels": [
      "lane:V",
      "size:L",
      "wave:S5-W3"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S21",
      "S22",
      "D52"
    ],
    "links": []
  },
  {
    "key": "V22",
    "title": "Intelligence native pages, group 1 (brands, browse, families, transcripts, intel, gold, settings, tools)",
    "description": "## V22 \u00b7 Intelligence native pages, group 1 \u00b7 V \u00b7 L \u00b7 S2-W3\n**Read first.** Appendix A.1 rows for brands, browse, families, transcripts, intel, gold, settings, tools; the old pages `/brands*`, `/ads`, `/intel`, `/intel/gold`, `/settings`, `/users`; the A9 API; A13 model.\n**Deliverables.** `apps/web/src/intelligence/`: Competitors list + detail (lenses, schedules, metrics, families), Ads browse (label filters, status, span, collation, family collapse, running-now, video/transcript, sorts, tags, signed media), Intel dashboards (coverage, label distributions, compare), Gold labelling (blind item, label, run evaluation), Settings (models, cost caps, residential, proxies), Tools (manual runs: scrape, crawl, classify, transcribe; backfill family keys; classifier calibration; keyword seeding helpers; probe runs), Rank page (`/rank` + history, freshness diff, rank snapshots), an Intelligence users view that deep-links to Skynet core membership management (login = core auth; the fold is explicit in the checklist). Auth = Skynet roles (`researcher`, `admin`); the old `admin_users` retired with the Next app.\n**Acceptance.** `pnpm --filter web test -- intelligence-group1`; parity checklist `docs/checklists/intelligence-group1.md` with every A.1 row in scope green (screenshots), including `Quick Scrape / rank`, `Settings/users/login` and `Tools`.",
    "labels": [
      "lane:V",
      "size:L",
      "wave:S2-W3"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "A9",
      "A13a"
    ],
    "links": []
  },
  {
    "key": "V23",
    "title": "Script Studio native pages",
    "description": "## V23 \u00b7 Script Studio native pages \u00b7 V \u00b7 L \u00b7 S3-W4\n**Deliverables.** `apps/web/src/studio/`: scripts list + detail (creative DNA, revisions, votes, assignment, sign-off, learnings, handoff), sessions (live stream, gates: reply/question/permission, artifacts, save handoff), scoreboard (readback-fed), opportunities, backlog, overview, night-shift, admin usage, audit, users (deep-linked to core membership), admin/supermemory; a compact handoff artefact (current state, decisions, changed artefacts, open loops, the exact next step, a resume prompt) that can seed the next run; old console URL \u2192 redirect after A20.\n**Acceptance.** `pnpm --filter web test -- studio-shell-pages`; walkthrough recording (scripts \u2192 session \u2192 gate \u2192 sign-off).",
    "labels": [
      "lane:V",
      "size:L",
      "wave:S3-W4"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "A21",
      "V14"
    ],
    "links": []
  },
  {
    "key": "V24",
    "title": "Board batch 2 native UI",
    "description": "## V24 \u00b7 Board batch 2 native UI \u00b7 V \u00b7 M \u00b7 S4-W4\n**Context.** Batch 2 moves the highest-coupling creative-workflow boards native while ClickUp remains the rollback mirror.\n**Read first.** S26 API contract, D42 board-authority doc, S16 script-bridge state map.\n**Deliverables.** Boards for Demand (weeks, paste \u2192 parsed lines, bounce rows, lock), Scripts, Creatives (structural fields, briefs, QA-gate status, assignment, spawn visibility), rollback state per board.\n**Acceptance.** `pnpm --filter web test -- workspace-batch2`; parity checklist signed by the list owners.\n**Out of scope.** Launches, Products, Competitors, Research Intake, Rip Sources, Campaigns, Adsets, Email Flows, company-ops lists.",
    "labels": [
      "lane:V",
      "size:M",
      "wave:S4-W4"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S26"
    ],
    "links": []
  },
  {
    "key": "V25",
    "title": "Board batch 3 native UI",
    "description": "## V25 \u00b7 Board batch 3 native UI \u00b7 V \u00b7 L \u00b7 S4-W5\n**Context.** Batch 3 moves the entity and intake boards native so research/product setup no longer depends on ClickUp for creative-workflow work.\n**Read first.** S18 API + mirror contracts; A13 competitor entities; A15 Promote to product; D42 board-authority doc.\n**Deliverables.** Native boards for Products, Competitors, Research Intake and Rip Sources with per-board rollback state, owner/status visibility, provenance indicators and direct links into Intelligence/Product flows. Products show the naming product number + linked ClickUp id and the unit-economics fields (S18); Competitors show linked A13 entities; Research Intake shows promote/reject state; Rip Sources feed Expand.\n**Acceptance.** `pnpm --filter web test -- workspace-batch3`; parity checklist signed by the list owners; one dual-run walkthrough attached.\n**Out of scope.** Launches, Demand, Scripts, Creatives, Campaigns, Adsets, Email Flows, company-ops lists.",
    "labels": [
      "lane:V",
      "wave:S4-W5",
      "size:L"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S18"
    ],
    "links": []
  },
  {
    "key": "V26",
    "title": "Final batch native UI + Workspace as the default path",
    "description": "## V26 \u00b7 Final batch native UI + Workspace as the default path \u00b7 V \u00b7 L \u00b7 S4-W7\n**Context.** The last creative-workflow boards go native and Workspace becomes the default entry path; company-ops stays in ClickUp.\n**Read first.** S19 API + mirror contracts; D42 board-authority doc (incl. the `ugcCreators` schema); the shell nav (V6).\n**Deliverables.** Native boards for Funnels/Landers, UGC Creators, Email Flows, Campaigns and Adsets; nav default switched to native Workspace; company-ops deep links remain explicit ClickUp links; the read-only state of the retired ClickUp lists is visible in the UI.\n**Acceptance.** `pnpm --filter web test -- workspace-batch4`; nav walkthrough attached showing the native default path and the ClickUp-only company-ops links.\n**Out of scope.** Company-ops board replacement.\n",
    "labels": [
      "lane:V",
      "wave:S4-W7",
      "size:L"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S19"
    ],
    "links": []
  },
  {
    "key": "V2a",
    "title": "Shell skeleton",
    "description": "## V2a \u00b7 Shell skeleton \u00b7 S \u00b7 `feat/v/v2a-shell`\n**Context.** The unified frontend is the static-ads frontend grown into a shell. The nav becomes the product's map.\n**Read first.** Functional design \u00a73 (modules), \u00a75; `App.tsx` L47\u2013138; `Sidebar.tsx` L42\u201344, L107\u2013307; `index.css` `@theme` L7\u201356; lock doc \u00a74.1 cookie names.\n**Deliverables.** (1) `src/shell/`: `ShellLayout.tsx` (top nav: Intelligence \u00b7 Strategy (stub page \"a later slice\") \u00b7 Studio \u00b7 Production \u00b7 Workspace \u00b7 Inbox (stub) \u00b7 Admin; workspace switcher reading `/api/auth/me` \u2014 consumes exactly the S1a shape `{id, email, name, ws, ws_slug, roles, permissions, feature_flags?}`), `routes.tsx` (route folders `/intelligence`, `/strategy`, `/studio`, `/production/*` = all existing routes moved under the prefix with redirects from old paths, `/workspace`, `/inbox`, `/admin`), `auth/` (`LoginPage` posting to `/api/auth/login`), `RequirePermission.tsx` (gates on `permissions[]`, never on role names). (2) Theming: keep tokens; product name appears only in the nav brand component (`BRAND_NAME` const, one place). (Invite/reset pages are delivered in V6 after S1b; the session-token refresh hook is V1's.)\n**Acceptance.** `pnpm --filter web build` clean; `pnpm --filter web lint` clean; local: every existing Production page renders under `/production/*`; old URLs redirect; a user lacking `admin.settings.manage` cannot open `/admin` (screenshot).\n**Out of scope.** Legacy mounts (V6), launch board (V9), admin content (V5).",
    "labels": [
      "lane:V",
      "size:S",
      "wave:W1"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v2a-shell"
      }
    ]
  },
  {
    "key": "V3",
    "title": "Production on unified Postgres",
    "description": "## V3 \u00b7 Production on unified Postgres \u00b7 M \u00b7 `feat/v/v3-production-pg`\n**Context.** Production's Postgres lives inside its container today (supervisord starts it; `init-db.sh` re-runs `schema.sql` every boot). On unified PG it becomes schema `production` with real migrations.\n**Read first.** `Dockerfile` L38\u201356, L131\u2013140; `supervisord.conf`; `docker/init-db.sh` L12\u201319, L38\u201397; `schema.sql` (63 `CREATE TABLE IF NOT EXISTS`, migrations block from L336 \u2014 idempotent `ALTER \u2026 IF NOT EXISTS`); `app/backend/db.py`; implementation PRD \u00a75 (migrate step: ledger, checksum, pre-dump).\n**Deliverables.** (1) `db/migrations/unified/production/001_baseline.sql` = `schema.sql` rewritten for schema `production` (`create schema if not exists production; set search_path = production, public;` + `create extension if not exists vector` in `public`), unchanged otherwise. (2) `apps/api/migrate.py` (the runner, shared with core): applies `db/migrations/unified/*/NNN_*.sql` in order inside one transaction each, records in `core.schema_migrations(version, checksum, applied_at)`, refuses a changed checksum, `--dry-run`, exit \u2260 0 on error; `docker compose run --rm migrate` entrypoint. (3) `db.py`: `DATABASE_URL` + `options=-c search_path=production,public`. (4) Dockerfile: remove Postgres install (L38\u201356), `init-db.sh`, `postgresql` program; keep `uvicorn` + `scrape_worker`. (5) `docs/MIGRATIONS.md` (forward-only, additive during S1, how to add a file). (6) Ops endpoints in `app/backend/main.py`: `GET /api/healthz` \u2192 `{\"ok\":true}` once the API is up and can read the migration ledger; `GET /api/version` \u2192 `{\"sha\":\"<git sha>\",\"migration_head\":\"<latest applied version>\"}` (sha baked in at build via `GIT_SHA` build arg). These are the endpoints A2/A6/X3/X5 and the smoke gate use.\n**Acceptance.** `python apps/api/migrate.py --dry-run` lists 001; `pytest app/backend/tests` green against unified PG in the existing `docker-compose.yml` dev stack (postgres service added there; A2 introduces the monorepo compose files later); full Explore + Storyboard run on the local stack (session created, images generated, storyboard delivered \u2014 screenshots); image no longer contains `postgres` binary (`docker run \u2026 which postgres` \u2192 empty); `curl -s localhost:8000/api/healthz` \u2192 `{\"ok\":true}` and `curl -s localhost:8000/api/version` returns the current git sha plus the latest applied migration version.\n**Out of scope.** Data migration of the live DB (S12/X7); R2 changes.",
    "labels": [
      "lane:V",
      "size:M",
      "wave:W1"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "A1a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v3-production-pg"
      }
    ]
  },
  {
    "key": "V4",
    "title": "Model bindings + spend guard (core)",
    "description": "## V4 \u00b7 Model bindings + usage events + spend guard \u00b7 L \u00b7 `feat/v/v4-bindings-spend-guard`\n**Context.** Which model runs which job becomes a setting, not a deploy; spend gets a guard. Production is the first consumer; sidecars post usage later.\n**Read first.** S4b contract (`packages/contracts/bindings`); `app/pipeline/config.py` L111\u2013216 (`IMAGE_MODEL_IDS`, `CLAUDE_MODEL`, `load_settings_from_db` writing module globals); `app/backend/services/system_settings_service.py` (`resolve` L280, `set_value` L294); technical plan \u00a72.4.4.\n**Deliverables.** (1) Migration `core/006_bindings.sql`: `core.providers(id, workspace_id, kind check in ('openrouter','vendor','claude_subscription'), name, base_url, config_encrypted, created_at)`, `core.models(id, provider_id, slug, modality check in ('chat','image','video','tts','embedding'), price_in_usd, price_out_usd, active)`, `core.model_bindings(workspace_id, workflow_key, model_id, provider_id, updated_by, updated_at, pk(workspace_id, workflow_key))`, `core.usage_events(id bigserial, workspace_id, service, workflow_key, model, tokens_in, tokens_out, cost_usd numeric(12,6), request_id, created_at)`, `core.spend_guards(workspace_id pk, window_hours default 24, warn_usd, pause_usd, paused_at, paused_by, resumed_at)`. Seed for `rescale`: OpenRouter provider (key from env), models = today's `CLAUDE_MODEL`, `LONGFORM_OPUS_MODEL`, `HAIKU_MODEL`, `GENDER_DETECT_MODEL`, the three `IMAGE_MODEL_IDS`; bindings for workflow keys `production.concepts`, `production.longform`, `production.image`, `production.analysis`, `production.embeddings`, `production.storyboard.llm`, `production.video`, `production.tts`. (2) `apps/api/core/bindings/`: `GET /api/bindings/:workflow` (resolves binding \u2192 provider config decrypted server-side, returns the contract shape + `paused` + guard state), `POST /api/usage` (service token or user), `PUT /api/admin/bindings/:workflow`, `PUT /api/admin/spend-guard`, `POST /api/admin/spend-guard/resume`; admin read endpoints required by V5: `GET /api/admin/bindings` (all workflow keys, current binding, available models grouped by modality, masked provider metadata) and `GET /api/admin/spend?window=24h|7d` (totals per provider and workflow from `core.usage_events` plus the current guard state); `guard.py` (rolling sum over `window_hours`; \u2265 `warn_usd` \u2192 one Discord/Telegram message per hour; \u2265 `pause_usd` \u2192 set `paused_at`; `SPEND_GUARD_KILL_SWITCH=1` env pauses everything). (3) `apps/api/core/gateway.py`: `@gated(workflow_key)` decorator: pre-call `resolve_binding()` \u2192 raise `SpendPaused` (HTTP 402, message \"Spend guard paused this workspace at \u20acX; an admin can resume in Admin \u2192 Spend\") if paused; post-call `record_usage(tokens, cost)`; cost from `models.price_*` or the provider's usage field.\n**Interfaces.** Exposes the contract endpoints; V8 wires production calls; V5 is the UI.\n**Acceptance.** `pytest app/backend/tests/core/test_bindings.py` green: change the binding for `production.concepts` \u2192 next `resolve_binding` returns the new model (no restart); usage of 3 events sums correctly; crossing `pause_usd` pauses and the decorator raises 402 with the message; resume clears. Post output.\n**Out of scope.** The admin UI (V5), wiring the call sites (V8), per-user keys (M1).",
    "labels": [
      "lane:V",
      "size:L",
      "wave:W2"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S1a",
      "S4b"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v4-bindings-spend-guard"
      }
    ]
  },
  {
    "key": "V5",
    "title": "Minimal admin page: bindings + spend",
    "description": "## V5 \u00b7 Minimal admin page: bindings + spend \u00b7 S \u00b7 `feat/v/v5-admin-bindings`\n**Read first.** V4 endpoints.\n**Deliverables.** Use V4's admin read endpoints only: `/admin/bindings` (table of workflow keys \u2192 model dropdown from `GET /api/admin/bindings`, save \u2192 `PUT /api/admin/bindings/:workflow`), `/admin/spend` (totals per provider/workflow for 24 h / 7 d from `GET /api/admin/spend`, guard thresholds form, pause state, Resume button \u2192 `POST /api/admin/spend-guard/resume`), providers list with masked keys (last 4, from the same endpoint). Role `admin` only.\n**Acceptance.** Screen recording: change a binding, run a job, see the new model in the usage row; trigger a pause, resume from the UI \u2014 no DB access.\n**Out of scope.** Users/roles UI (V11, later slice), charts.",
    "labels": [
      "lane:V",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V4"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v5-admin-bindings"
      }
    ]
  },
  {
    "key": "V6",
    "title": "Shell with links to the standalone apps",
    "description": "## V6 \u00b7 Shell with links to the standalone apps \u00b7 S \u00b7 W3 \u00b7 `feat/v/v6-shell-links` (S1-rev)\n**Context.** Day-14 shell = Production native + links. No mounts, no SSO into the standalones.\n**Read first.** Architecture doc \u00a72 (boundaries), functional design \u00a75; V2a routes.\n**Deliverables.** Nav entries: **Intelligence** \u2192 `https://meta-ads.rescale.media` (new tab, label \"opens the scraper \u2014 separate login for now\"); **Studio** \u2192 the console URL (same treatment); **Workspace** \u2192 ClickUp Launches list deep link + the pilot placeholder (\"native boards come with the Workspace slice\"); **Strategy**, **Inbox** stubs with one sentence each; **Admin** \u2192 V5. Link targets from env (`LINK_SCRAPER_URL`, `LINK_STUDIO_URL`, `LINK_CLICKUP_LAUNCHES_URL`). `docs/INTEGRATION_CHECKLIST.md` rows: login, each nav entry, logout, one SSE stream, one upload.\n**Acceptance.** Checklist green on staging (screenshots); `viewer` cannot open `/admin`.\n**Out of scope.** Any native Intelligence/Studio page (S2/S3).",
    "labels": [
      "lane:V",
      "wave:W3",
      "size:S"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V2a"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v6-shell-routes"
      }
    ]
  },
  {
    "key": "V7",
    "title": "Creative code on export + lineage writes",
    "description": "## V7 \u00b7 Creative code on export + lineage writes \u00b7 M \u00b7 `feat/v/v7-export-code-lineage`\n**Context.** The stamp. Every exported asset carries a creative code from S3; the chain is written to lineage.\n**Read first.** Lock doc \u00a74.2 (stamping rules); S3 routes; S4 API; `routes/products.py:213` `POST /{product}/export` \u2192 `export_service.export_product` L18; `routes/video_storyboard.py` zip paths L1079/L1170; `lib/download.ts` (`downloadImagesAsZip` L17); `product_creatives.creative_code` (human-typed today, `routes/products.py:450`).\n**Deliverables.** (1) Export flow: on export of a concept/asset, call `POST /api/creative-codes` (`kind` from asset type: image \u2192 `TM`, video \u2192 `RC`; `team_made` = session mode Explore \u2192 true, Expand \u2192 false; `product_number` from the product's naming product number \u2014 add `products.product_number` if absent, editable in product settings) \u2192 stamp: filename `<code>__<concept-slug>.<ext>`, `manifest.json {code, product_number, session_id, concept_id, naming_version:'v3', assembled_ad_name?}`, the copy-ready ad name from `POST <WORKOS_SERVICE_URL>/naming/ad-name` when S8 is live (else omitted, logged). (2) Lineage batch to S4: nodes session \u2192 concept \u2192 asset \u2192 export \u2192 creative_code, edges in order. (3) `product_creatives` upload form: `creative_code` becomes read-only, prefilled from the issued code; existing free-text codes are left as-is and reported by a new script `scripts/report_unmatched_creative_codes.py` added in this PR (CSV output: product, creative_code, reason). (4) Client zip (`download.ts`) uses the stamped filenames from the API response.\n**Acceptance.** `pytest app/backend/tests/test_export_code.py` (mocked S3/S4: filenames + manifest carry the code; lineage batch posted once); manual: export one concept on the local stack, unzip, show manifest; `GET /api/lineage/chain?type=creative_code&ref=<code>` returns 5 nodes (paste JSON).\n**Out of scope.** Uploading to Meta; winner display (V10).",
    "labels": [
      "lane:V",
      "size:M",
      "wave:W3"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S3",
      "S4"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v7-export-code-lineage"
      }
    ]
  },
  {
    "key": "V8",
    "title": "Gateway client in production",
    "description": "## V8 \u00b7 Gateway client in production \u00b7 M \u00b7 `feat/v/v8-gateway-client`\n**Context.** Every outbound model call in Production goes through the binding + guard from V4. There are seven choke points.\n**Read first.** V4 `gateway.py`; call sites: `claude_client.py::_call_claude_impl` L44 (workflow `production.concepts` / `production.longform` chosen by caller \u2014 add a `workflow_key` kwarg with defaults per caller module), `analysis_llm.py::chat` L53 (`production.analysis`), `openrouter_client.py::generate_image` L57 (`production.image`), `embeddings.py::_post_with_retry` L43 (`production.embeddings`), `fal_client.py::_request` L198 and `heygen_client.py::_request` L171 (`production.video`), `elevenlabs_client.py::_tts_request` L483 (`production.tts`); `render_provider.py` (`active_provider` L49) and `config.py::resolve_image_model` L141.\n**Deliverables.** Each choke point wrapped with `@gated(workflow_key)`: model slug + base URL + key come from `resolve_binding()` (fallback to today's env only when no binding exists, with a warning log); usage posted with real token counts where the provider returns them (OpenRouter `usage`), else the model's `price_*` \u00d7 estimate; `resolve_image_model` reads bindings (`production.image` default; the UI's model dropdown lists `core.models` with modality image). Remove the import-time binding of `OPENROUTER_API_KEY` in `claude_client.py` L8/L132 (late-bind).\n**Acceptance.** `pytest app/backend/tests/test_gateway_client.py` (each choke point calls `resolve_binding` and `record_usage`; paused \u2192 402 surfaces to the route as a clear error); staging: run one Explore session \u2192 Admin \u2192 Spend shows usage rows for `production.concepts` and `production.image` (screenshot); set `pause_usd` low \u2192 next run blocked with the message.\n**Out of scope.** Sidecar usage posting (later slices), pricing accuracy beyond OpenRouter's usage field.",
    "labels": [
      "lane:V",
      "size:M",
      "wave:W4"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "V4"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v8-gateway-client"
      }
    ]
  },
  {
    "key": "V9",
    "title": "Launch board native UI",
    "description": "## V9 \u00b7 Launch board native UI \u00b7 V \u00b7 L \u00b7 S4-W2\n**Deliverables.** `apps/web/src/workspace/launch-board/`: queue, detail (package, code, ad name copy, assets), status transitions (pre-launch only), assignee, filters, conflict banner, reconciliation state; flag `launch_board` per workspace.\n**Acceptance.** `pnpm --filter web test -- launch-board`; the named media buyer completes one launch end-to-end on staging (recording).",
    "labels": [
      "lane:V",
      "size:L",
      "wave:S4-W2"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "S8b"
    ],
    "links": [
      {
        "label": "branch",
        "url": "feat/v/v9-launch-board-ui"
      }
    ]
  },
  {
    "key": "X1",
    "title": "Auth/session policy tests",
    "description": "## X1 \u00b7 Auth/session policy tests \u00b7 S \u00b7 W6 \u00b7 `test/a/x1-auth-policy` (S1-rev)\n**Deliverables.** `e2e/auth-policy.spec.ts` (Playwright against staging): cookie flags; refresh rotation (old \u2192 401, reuse revokes family); logout \u2192 `/api/auth/me` 401 immediately, a static-ads `?token=` image URL \u2192 401 within 60 s; CSRF: mutating request with `Origin: https://evil.example` \u2192 403; `docs/AUTH_SESSION.md` final (Skynet-only scope, `?token=` bridge = ADR-005).\n**Acceptance.** Spec green (run link); doc merged.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W6"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "S1a",
      "A3",
      "V1"
    ],
    "links": []
  },
  {
    "key": "X2",
    "title": "Upload/download through proxy",
    "description": "## X2 \u00b7 Upload / download / SSE through the proxy \u00b7 S \u00b7 W5 (S1-rev)\n**Deliverables.** Four checks on staging, in `docs/checklists/proxy-io.md`: (1) 400 MB document upload `POST /api/products/{product}/documents` via the shell; (2) storyboard clip download `GET /api/video-storyboard/sessions/{id}/scenes/{scene}/clip/{variant}` (signed R2); (3) 20-min Explore SSE `GET /api/products/{product}/sessions/{id}/progress/stream` stays open; (4) a storyboard render job survives a Caddy restart (queue leases). Pass/fail + timings.\n**Acceptance.** All four pass on staging.",
    "labels": [
      "lane:V",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "vince",
    "depends_on_keys": [
      "A3",
      "A6",
      "V6"
    ],
    "links": []
  },
  {
    "key": "X3",
    "title": "Synthetic checks + backup freshness + status page",
    "description": "## X3 \u00b7 Synthetic checks + backup freshness + status page \u00b7 S \u00b7 W5 \u00b7 `feat/a/x3-synthetic` (S1-rev)\n**Deliverables.** `infra/synthetic/check.sh` (5-min cron sidecar, prod + staging): `/`, `/api/healthz`, `/api/auth/jwks`, workos bridge `/health` on Railway, one read-layer query (`GET /api/performance?code=<known>`), backup freshness (newest file in `/var/lib/skynet-backups/daily` < 24 h), host pressure (load > 24 for 10 min, available RAM < 32 GB, swap > 2 GB, disk free < 100 GB); two consecutive failures \u2192 Discord webhook + Telegram; rows in `core.synthetic_checks`; `/status` behind SSO. `docs/runbooks/ALERTS.md`.\n**Acceptance.** `docker stop skynet-staging-api-1` \u2192 alert within 10 min (screenshots); rename last night's dump \u2192 backup-freshness alert; `/status` shows both; containers restored.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A6",
      "V3",
      "X7"
    ],
    "links": []
  },
  {
    "key": "X3b",
    "title": "Supabase-protection smoke (Skynet under caps, Supabase latency unchanged)",
    "description": "## X3b \u00b7 Supabase-protection smoke \u00b7 S \u00b7 W5 (S1-rev)\n**Context.** Prove Skynet under its caps cannot degrade the production Supabase.\n**Deliverables.** `infra/smoke/pressure.sh`: on the production box (before DNS flip, X7 stack up): run 4 concurrent Explore sessions + 2 storyboard renders + a 400 MB upload against `127.0.0.1:38000` for 20 min while sampling every 30 s: `docker stats` for `skynet-*` and `supabase-db/pooler/kong`, `uptime`, and a `pg_stat` latency probe against the ecomprofits pooler (`select 1` p95 via `pgbench -S` on the read-only role, 1 client). Report in `docs/ops/pressure-test-<date>.md`.\n**Acceptance.** Skynet containers never exceed their caps (no OOM, no throttling beyond `cpus`); Supabase p95 latency during the run within 10 % of the 10-min baseline before it; host load < 24. If not, A2c limits are lowered and the test repeated \u2014 no go-live before it passes.",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A2c",
      "X7"
    ],
    "links": []
  },
  {
    "key": "X4",
    "title": "Backup restore drill",
    "description": "## X4 \u00b7 Backup restore drill \u00b7 S \u00b7 W5 \u00b7 `chore/a/x4-restore` (S1-rev)\n**Deliverables.** `infra/backup/restore.sh <dump> <target-container>` + `infra/backup/compare_rowcounts.py` (two Postgres URLs, `information_schema.tables`, `count(*)` per table for `core` + `production`); drill on staging: restore last night's dump into a scratch `postgres:17` container, compare, time it; `docs/runbooks/RESTORE.md`.\n**Acceptance.** Runbook with output (counts equal, minutes); Sead repeats it from the runbook and comments \"reproduced\".",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A6"
    ],
    "links": []
  },
  {
    "key": "X4b",
    "title": "Backup path + sweep validation on the production box",
    "description": "## X4b \u00b7 Backup path + sweep validation on the production box \u00b7 S \u00b7 W5 (S1-rev)\n**Read first.** Architecture doc \u00a710; A0 (folders, Scheduled Task).\n**Deliverables.** With the X7 stack up on `server.ecomprofits.io`: the `backup` sidecar writes `/var/lib/skynet-backups/daily/skynet-<date>.dump` (mode 640, owner = backup account); the Plesk Scheduled Task runs and the file appears on the Storage Box under `skynet/` (`ls` over SSH port 23); retention 20 days enforced by the sidecar (`find -mtime +20 -delete` limited to that folder); a pre-deploy dump lands in `pre-deploy/` on the first prod deploy; `X3` freshness check green. `docs/runbooks/BACKUP.md`.\n**Acceptance.** Screenshot/ls of the file on the Storage Box; freshness check green; permissions listed.\n",
    "labels": [
      "lane:A",
      "size:S",
      "wave:W5"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A0",
      "X7"
    ],
    "links": []
  },
  {
    "key": "X5",
    "title": "Rollback drill + cutover matrix incl. old-host cron shutdown plan",
    "description": "## X5 \u00b7 Rollback drill + cutover matrix \u00b7 M \u00b7 W4 \u00b7 `chore/a/x5-rollback-cutover` (S1-rev)\n**Deliverables.** `infra/deploy/rollback.sh <sha>` (reset checkout to `<sha>`, pin `skynet-api@<digest>` + `skynet-web@<digest>` from that run, `up -d`, restore the pre-migrate dump if the ledger head is newer) \u2014 drilled on staging; `docs/runbooks/ROLLBACK.md`; `docs/CUTOVER.md` matrix: old static-ads host \u2192 301 to `app.rescale.media/production/*`; scraper and console URLs unchanged (links); workos untouched; DNS + TLS on the ecomprofits box; old-host crons: static-ads `scrape_worker` stopped at go-live, everything else stays; owner + trigger per row; the 14-day read-only window (M5).\n**Acceptance.** Rollback on staging under 15 min (transcript); matrix acknowledged by Vince and Sead on the task.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:W4"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A6",
      "V3"
    ],
    "links": []
  },
  {
    "key": "X7",
    "title": "Production stack prepared on server.ecomprofits.io + go-live checklist",
    "description": "## X7 \u00b7 Production stack prepared on `server.ecomprofits.io` + go-live checklist \u00b7 M \u00b7 W4 \u00b7 `chore/a/x7-prod` (S1-rev)\n**Read first.** A0, A2c, A3b; architecture \u00a75, \u00a77, \u00a712.\n**Deliverables.** `SKYNET_ENV_B64` secret (prod values, escrowed); one manual `workflow_dispatch` deploy to `/opt/skynet` with `DEPLOY_PROD_ENABLED` still false in the automatic path: stack healthy on `127.0.0.1:38000`, limits visible in `docker stats`; `docs/GO_LIVE.md`: T-1 (freeze, staging smoke, S12 dry run repeated, X3b pressure test passed), T-0 (stop old `scrape_worker` \u2192 final `pg_dump` of the old static-ads DB \u2192 `migrate` \u2192 restore into `production` \u2192 checksums \u2192 `up -d` \u2192 smoke \u2192 DNS flip \u2192 old-host redirect \u2192 comms), T+1 (synthetic green 24 h, dump on the Storage Box), owners, rollback trigger.\n**Acceptance.** `docker compose -p skynet ps` all healthy on the production box; `curl --resolve app.rescale.media:443:<ip> https://app.rescale.media/api/healthz` \u2192 ok; checklist signed by Agon + Sead.",
    "labels": [
      "lane:A",
      "size:M",
      "wave:W4"
    ],
    "assignee_username": "agon",
    "depends_on_keys": [
      "A0",
      "A3b",
      "A6",
      "X5"
    ],
    "links": []
  }
]