---
status: reviewed
author: Claude
version: 6
created_at: 2026-05-14
reviewed_by: [haiku, sonnet, opus]
---

# Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature)

## Context

Bij het aanmaken van een nieuw Product in Scrum4Me wil de user direct een GitHub-repo bootstrappen volgens canonical conventies (MD3-theme, ADR-systeem, docs-structuur, tooling). De catalogus van aanvinkbare opties + uitvoer-recepten leeft in de **database** (configureerbaar, audit-bar). Uitvoering gebeurt server-side via een **aparte `bootstrap-service`** — geen Claude-CLI, geen serverless fire-and-forget.

**v3.2 verwerkt twee reviews + vijf pre-implementatie correcties + vijf clarificaties**:

- **v1 review**: deterministic runtime, status-sync, schema-relaties, secret-boundary, action-validatie, ADR-coverage, route-groups, repo-slug
- **v2 web-research review**: één executor-model, PAT-shape, dry-run als feature, owner-picker, action-permissions, catalog-versioning, conditie weg uit MVP, tag-pinning behouden
- **Pre-implementatie correcties (v3.1)**:
  1. Snake-case DB-tables via `@@map`
  2. NOTIFY payload contract: `type: 'claude_job_status'`, `user_id` verplicht
  3. `lease_until` veld (bestaand) voor lease-renewal
  4. Git push via `isomorphic-git` (in-process credentials)
  5. `bootstrap-service` als sibling-directory
- **Pre-implementatie clarificaties (v3.2)**:
  1. Shared package npm-publicatie: trigger op derde consumer of coordination-pijn
  2. Recipe-hash determinisme: hash over `recipe_snapshot` (niet `selected_options`), canonicalized
  3. Dry-run file-tree: gefilterd ignore-set + cap 500 entries
  4. Template cache: geen cache in MVP; fase-2 disk-cache met TTL-sweep
  5. Deployment target: multi-arch Dockerfile, Mac arm64 als primary
- **Plan v3.3 verwerkt review-v3.2** (5 P1 + 6 P2):
  1. **Claim-identiteit**: `claimed_by_worker_id String?` toegevoegd aan ClaudeJob (niet het bestaande `claimed_by_token_id` misbruiken)
  2. **Shared package in Scrum4Me-repo**: `packages/bootstrap-actions/` binnen deze repo (geen secrets, deploybaar); bootstrap-service consumeert via release
  3. **GitHub-side-effect checkpoints**: `github_repo_created_at`/`github_repo_id`/`github_repo_full_name`/`push_completed_at` + status `FAILED_NEEDS_CLEANUP`
  4. **Stale-recovery kind-filter**: SQL altijd `AND kind='BOOTSTRAP_REPO'`
  5. **Atomic enqueue**: pre-generated cuid IDs binnen transaction-array
  6. **Cancel-safe terminal sync**: conditional `updateMany` voor success/failure
  7. **`last_bootstrap_run_id`** met expliciete Prisma-relation + relation-name (om ambiguïteit met `Product.bootstrap_runs` te voorkomen) + `onDelete: SetNull`
  8. **Action-permissions naar action-niveau**: `risk_level`/`requires_role` op `BootstrapAction`; option-level derived van max
  9. **ID-strategie**: alle nieuwe modellen gebruiken `@default(cuid())` (consistent met 22 bestaande modellen)
  10. **Classic PAT MVP**: scope-detectie via `x-oauth-scopes` werkt alleen voor classic; fine-grained PATs als open punt
  11. **`.env.example` + deployment docs** in filelijst
- **Plan v3.4 verwerkt review-v3.3** (3 P1 + 4 P2):
  1. **Status-sync echt transactioneel**: één `prisma.$transaction(async tx => …)` callback met alle 3 updates + count-checks; lease_until + claimed_by_worker_id terminal op null
  2. **Stale-recovery split**: `FAILED_NEEDS_CLEANUP` alleen bij `github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL`; rest `FAILED`
  3. **Geen `@paralleldrive/cuid2` dep**: transaction-callback vorm met door Prisma gegenereerde cuid's
  4. `User.github_pat_scopes` krijgt `@default([])` voor migration-safety
  5. NOTIFY-payload `status` is **lowercase** (`jobStatusToApi`-output); DB blijft UPPER_SNAKE
  6. Stale-recovery komt naar **Sprint 1d** (MVP), niet pas fase-2
  7. **Org-owner preflight via Octokit-call**: `RepoOwnerPicker` toont alleen owners waarvoor `octokit.repos.create…`-preflight slaagt; scope alleen is niet genoeg
- **Plan v3.5 verwerkt review-v3.4** (4 P2 + 3 P3 — geen P1's; go-signaal na P2-verwerking):
  1. **Org-owner preflight expliciet best-effort**: discovery toont owners; collision via `GET /repos/{owner}/{repo}`; finale autorisatie pas bij service-create; 403/422 → duidelijke wizard-fout; org-policy via `members_can_create_repositories` waar beschikbaar; ontbrekende info = "unknown" (niet automatisch verbergen)
  2. **`syncRunning` timestamp-contract**: bootstrap_runs.started_at én claude_jobs.started_at in **dezelfde transaction** met **dezelfde `now`-waarde**; unit-test voor PENDING/CLAIMED → RUNNING
  3. **`catalog_version` deterministisch**: canonical JSON over categories+options+actions, gesorteerd op display_order/slug/execution_order, alle relevante velden geïncludeerd, sha256 (niet md5)
  4. **E2E verification-query** JOIN naar `claude_jobs` voor `lease_until`
  5. **Stale-recovery globaal** (geen `claimed_by_worker_id`-filter); `claimed_by_worker_id` alleen voor renewal/observability
  6. **Vendor-copy drift CI-check** als concrete sprint-taak in Sprint 1a + verificatie-stap; service logt geladen `ActionSchema`-hash bij startup
  7. **`ADD_DEPENDENCY.version` regex**: MVP expliciet "alleen exact/range semver"; fase-2 `npm-package-arg`-parser voor `latest`/prerelease/`workspace:*`/`npm:`-aliases

---

## Architectuur-besluiten

| # | Onderwerp | Keuze |
|---|---|---|
| 1 | Scope | Server doet GitHub-side via **Octokit** (repo-create) + **isomorphic-git** (clone + push) |
| 2 | DB-model | Declaratieve recepten + Zod-validatie + action-permissions |
| 3 | Wizard | Gemengd radio/checkbox + **dry-run preview-stap** |
| 4 | Uitvoer | `ClaudeJobKind.BOOTSTRAP_REPO` voor uniforme UI/status; **aparte `bootstrap-service` claimt** |
| 5 | GitHub-auth | Per-user PAT (encrypted); service decrypt per run binnen execution-boundary |
| 6 | Schema | Product (`repo_owner`, `repo_slug`, `template_version`, …) + `BootstrapRun` + versioning-velden |
| 7 | UX | Twee-staps: Product → wizard (Configure → Preview → Run) |
| 8 | Catalog mgmt | Hybride — seed canonical, admin-UI fase-2 met `recipe_hash`-publish |

---

## Executor: nieuwe `bootstrap-service` (sibling-directory)

### Locatie

**Sibling-folder**: `~/Development/bootstrap-service/` — naast `Scrum4Me/`, `scrum4me-mcp/`, `scrum4me-docker/`. Eigen `package.json`, `tsconfig.json`, `Dockerfile`, `prisma/schema.prisma` (gesynced via `sync-schema.sh` zoals scrum4me-mcp).

**Niet**:
- In deze repo (zou worker-secrets in app-build mengen)
- Monorepo-package (deze codebase heeft geen monorepo-tooling; zou aparte tooling-investering vereisen)

### Environment (`bootstrap-service/env.ts`)

```ts
const Env = z.object({
  DATABASE_URL: z.string().url(),
  DIRECT_URL: z.string().url(),                    // LISTEN/NOTIFY
  BOOTSTRAP_ENCRYPTION_KEY: z.string().min(32),    // AES-256-GCM key (gedeeld met app)
  BOOTSTRAP_TEMPLATE_REPO: z.string().default('madhura68/nextjs-baseline'),
  // GEEN ANTHROPIC_API_KEY, SESSION_SECRET, CRON_SECRET
})
```

### Claim-protocol (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`)

ClaudeJob krijgt een nieuw veld `claimed_by_worker_id String?` (separaat van bestaand `claimed_by_token_id` dat voor ApiToken-claim wordt gebruikt). Worker-ID is een unieke service-instance-identifier (`${hostname}-${pid}-${startTs}`).

```ts
// In bootstrap-service/src/claim.ts:
const row = await prisma.$queryRaw<{ id: string }[]>`
  UPDATE claude_jobs
  SET status = 'CLAIMED',
      lease_until = NOW() + INTERVAL '60 seconds',
      claimed_at = NOW(),
      claimed_by_worker_id = ${WORKER_ID}
  WHERE id = (
    SELECT id FROM claude_jobs
    WHERE status = 'QUEUED' AND kind = 'BOOTSTRAP_REPO'
    ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1
  )
  RETURNING id
`
```

**Lease-renewal**: setInterval(30s) doet `UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '60 seconds' WHERE id = $1 AND claimed_by_worker_id = ${WORKER_ID}` (only-mine-guard).

**Stale-recovery** — **strikt kind-gefilterd én split op externe side-effects**. Hoort in **Sprint 1d (MVP)**, niet pas fase-2; zonder dit kan een service-crash een job langdurig in `CLAIMED`/`RUNNING` laten hangen.

```sql
-- Stap 1: markeer verlopen BOOTSTRAP_REPO jobs als FAILED (DB-side)
UPDATE claude_jobs
SET status='FAILED', error='lease expired', finished_at=NOW(),
    lease_until=NULL, claimed_by_worker_id=NULL
WHERE status IN ('CLAIMED','RUNNING')
  AND kind = 'BOOTSTRAP_REPO'
  AND lease_until < NOW();

-- Stap 2a: runs met external side-effects → FAILED_NEEDS_CLEANUP (orphan repo mogelijk)
UPDATE bootstrap_runs
SET status='FAILED_NEEDS_CLEANUP', error='lease expired', finished_at=NOW()
WHERE status IN ('PENDING','RUNNING')
  AND claude_job_id IN (
    SELECT id FROM claude_jobs
    WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired'
  )
  AND (github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL);

-- Stap 2b: runs zonder external side-effects → FAILED (clean failure)
UPDATE bootstrap_runs
SET status='FAILED', error='lease expired', finished_at=NOW()
WHERE status IN ('PENDING','RUNNING')
  AND claude_job_id IN (
    SELECT id FROM claude_jobs
    WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired'
  )
  AND github_repo_full_name IS NULL
  AND github_repo_created_at IS NULL;
```

Bestaande Claude-runner cleanup (in `app/api/cron/cleanup-agent-artifacts/route.ts`) blijft ongemoeid — dit is een dedicated SQL-pad voor BOOTSTRAP_REPO.

**Wanneer draait dit**:
- In MVP via een dedicated cron-route (`app/api/cron/bootstrap-stale-recovery/route.ts`) elke 5 minuten, getrigggerd door Vercel-cron of de bestaande cron-runner. Bearer-secret-protected zoals andere cron-routes.
- `bootstrap-service` draait dezelfde SQL bij startup als **globale recovery** voor alle verlopen `BOOTSTRAP_REPO`-leases (van wie dan ook). **Niet** filteren op `claimed_by_worker_id` — een herstartende service heeft een nieuw `worker_id` (hostname + pid + start-timestamp) en zou zijn eigen oude leases anders niet matchen. `claimed_by_worker_id` is dus puur voor lease-renewal-only-mine-guard en observability/log-correlatie; stale-recovery is `kind`- en `lease_until`-gebaseerd.

**LISTEN-fallback**: service luistert op `scrum4me_changes` filtered op nieuwe enqueues (`type: 'claude_job_enqueued'`, `kind: 'BOOTSTRAP_REPO'`); poll-interval 30s als safety.

### `scrum4me-docker` skip-filter

`scrum4me-docker/bin/run-one-job.ts` claim-query toevoegen: `AND kind <> 'BOOTSTRAP_REPO'`. Twee runners delen de `claude_jobs`-tabel zonder overlap.

---

## Status-sync (transactional + post-commit NOTIFY)

```ts
// In bootstrap-service/src/status-sync.ts:
import { jobStatusToApi } from '@/lib/job-status'  // gespiegeld via shared-package of bootstrap-service-eigen kopie

async function syncSuccess(runId: string, jobId: string, productId: string, userId: string, repoUrl: string, templateVersion: string) {
  // ÉÉN transaction; geen partial commits.
  const result = await prisma.$transaction(async (tx) => {
    const runUpdate = await tx.bootstrapRun.updateMany({
      where: { id: runId, status: 'RUNNING' },
      data: { status: 'SUCCEEDED', finished_at: new Date(), repo_url: repoUrl, push_completed_at: new Date() },
    })
    if (runUpdate.count === 0) {
      // Was al CANCELLED of FAILED — abort, geen verdere writes
      return { committed: false as const }
    }
    const jobUpdate = await tx.claudeJob.updateMany({
      where: { id: jobId, status: 'RUNNING' },
      data: {
        status: 'DONE',
        finished_at: new Date(),
        summary: `Bootstrap completed: ${repoUrl}`,
        lease_until: null,
        claimed_by_worker_id: null,
      },
    })
    if (jobUpdate.count === 0) {
      // Race: job is buiten verwachting al terminal — gooi om transaction te rollbacken
      throw new Error('job-state-mismatch')
    }
    await tx.product.update({
      where: { id: productId },
      data: { repo_url: repoUrl, template_version: templateVersion, last_bootstrap_run_id: runId },
    })
    return { committed: true as const }
  })

  if (!result.committed) return   // niets te notifyen

  // NA commit:
  await prisma.$executeRaw`
    SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
      type: 'claude_job_status',
      job_id: jobId,
      user_id: userId,
      kind: 'BOOTSTRAP_REPO',
      status: jobStatusToApi('DONE'),    // 'done' lowercase per bestaand contract
      bootstrap_run_id: runId,
      repo_url: repoUrl,
    })}::text)
  `
}
```

**Belangrijke eigenschappen**:
- **Echt transactioneel**: bootstrap_runs, claude_jobs, en products updates zitten in dezelfde DB-transaction. Faal in een willekeurige stap → volledige rollback. Geen partial-committed-state mogelijk.
- **Cancel-safe**: status-filter in `where` zorgt dat een `CANCELLED`-overgang die tussendoor gebeurde niet door late terminal-write wordt overschreven.
- **Lease-cleanup terminal**: `lease_until` en `claimed_by_worker_id` worden expliciet `null` gezet bij terminal status; voorkomt dat stale-recovery deze record nog "ziet".
- **NOTIFY na commit**: pas na succesvolle commit; rollback betekent geen NOTIFY.
- **Status lowercase in payload**: `jobStatusToApi('DONE') → 'done'` matched bestaande SSE-clients; DB blijft UPPER_SNAKE.

Voor `syncFailed`: identieke vorm, met aanvullende beslislogica voor terminal-status:
```ts
const terminalRunStatus = (run.github_repo_full_name || run.github_repo_created_at)
  ? 'FAILED_NEEDS_CLEANUP'
  : 'FAILED'
```

Voor `syncRunning`: **cancel-safe** analoog aan syncSuccess én **timestamp-contract expliciet**. Beide tabellen krijgen `started_at = now` in dezelfde transaction met dezelfde `now`-waarde, zodat downstream metrics, UI-sortering en E2E-queries betrouwbaar zijn.

```ts
async function syncRunning(runId: string, jobId: string, userId: string) {
  const now = new Date()      // één waarde, gedeeld tussen run + job
  const result = await prisma.$transaction(async (tx) => {
    const runUpdate = await tx.bootstrapRun.updateMany({
      where: { id: runId, status: 'PENDING' },
      data: { status: 'RUNNING', started_at: now },
    })
    if (runUpdate.count === 0) return { committed: false as const }    // CANCELLED ertussen
    const jobUpdate = await tx.claudeJob.updateMany({
      where: { id: jobId, status: 'CLAIMED' },
      data: { status: 'RUNNING', started_at: now },
    })
    if (jobUpdate.count === 0) throw new Error('job-state-mismatch')   // rollback
    return { committed: true as const }
  })
  if (!result.committed) return

  await prisma.$executeRaw`
    SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
      type: 'claude_job_status', job_id: jobId, user_id: userId,
      kind: 'BOOTSTRAP_REPO', status: jobStatusToApi('RUNNING'),    // 'running'
      bootstrap_run_id: runId,
    })}::text)
  `
}
```

**Verplichte unit-test** (Sprint 1a): `bootstrap_runs.started_at == claude_jobs.started_at` na `syncRunning`; allebei niet-null; transitie PENDING/CLAIMED → RUNNING.

**GitHub side-effect checkpoint writes** (apart van terminal-sync, na elke external mutatie):
```ts
// Na Octokit createForAuthenticatedUser:
await prisma.bootstrapRun.update({
  where: { id: runId },
  data: {
    github_repo_created_at: new Date(),
    github_repo_id: repo.id,
    github_repo_full_name: repo.full_name,
  },
})
// Na isomorphic-git push success:
await prisma.bootstrapRun.update({
  where: { id: runId },
  data: { push_completed_at: new Date() },
})
```

Dit zorgt dat na een service-crash een stale-recovery weet wat er extern gebeurd is en kan beslissen tussen compensating delete vs. `FAILED_NEEDS_CLEANUP`-flag.

**Payload-contract** matched bestaand `JobPayload`-type uit `app/api/realtime/jobs/route.ts`:
```ts
type JobPayload = {
  type: 'claude_job_enqueued' | 'claude_job_status'
  job_id: string
  user_id: string                // verplicht voor SSE-filter
  kind?: string                  // 'BOOTSTRAP_REPO'
  status: string
  bootstrap_run_id?: string      // nieuw extension-veld voor deze kind
  task_id?: null
  idea_id?: null
  sprint_run_id?: null
  ...
}
```

`bootstrap_run_id` is een **additieve uitbreiding**; bestaande consumers negeren onbekende velden veilig.

---

## Git operaties: `isomorphic-git` (pure-JS)

**Waarom isomorphic-git, niet shell `git`**:
- Geen subprocess in service-container
- PAT als HTTP-header in-process (`onAuth` callback), nooit in URL of shell-argv
- Geen credential-helper-state op disk
- Werkt op alle platforms zonder git-binary-dependency

```ts
import * as git from 'isomorphic-git'
import http from 'isomorphic-git/http/node'
import fs from 'fs'

// Clone template (tag-pinned):
await git.clone({
  fs, http, dir: tmpdir,
  url: `https://github.com/${BOOTSTRAP_TEMPLATE_REPO}.git`,
  ref: templateVersion,       // bv. 'v1.0.0'
  singleBranch: true,
  depth: 1,
})
const sourceSha = await git.resolveRef({ fs, dir: tmpdir, ref: 'HEAD' })

// (recipe-acties muteren tmpdir hier)

// Init opnieuw als clean git-history voor target:
await fs.promises.rm(`${tmpdir}/.git`, { recursive: true, force: true })
await git.init({ fs, dir: tmpdir, defaultBranch: 'main' })
await git.add({ fs, dir: tmpdir, filepath: '.' })
await git.commit({
  fs, dir: tmpdir,
  message: `Bootstrap: ${selectedOptionsSummary}\n\nFrom template ${BOOTSTRAP_TEMPLATE_REPO}@${templateVersion}`,
  author: { name: user.github_username ?? 'Scrum4Me', email: 'bootstrap@scrum4me.dev' },
})

// Create remote repo via Octokit:
const octokit = new Octokit({ auth: pat })
const { data: repo } = await octokit.repos.createForAuthenticatedUser({  // of createInOrg
  name: repoSlug, private: true, auto_init: false,
})

// Push via isomorphic-git met token in onAuth-callback (nooit in URL):
await git.addRemote({ fs, dir: tmpdir, remote: 'origin', url: repo.clone_url })
await git.push({
  fs, http, dir: tmpdir, remote: 'origin', ref: 'main',
  onAuth: () => ({ username: 'x-access-token', password: pat }),  // header-only
})
```

`pat` blijft binnen function-scope; `onAuth` returnt closure-scoped value zonder logging.

---

## Domein-model (Prisma) — met `@@map`

### `BootstrapCategory` → table `bootstrap_categories`
```prisma
model BootstrapCategory {
  id              String   @id @default(cuid())
  slug            String   @unique
  label           String
  description     String?
  selection_type  BootstrapSelectionType    // SINGLE | MULTI
  display_order   Int
  is_required     Boolean  @default(false)
  options         BootstrapOption[]
  created_at      DateTime @default(now())
  updated_at      DateTime @updatedAt
  @@map("bootstrap_categories")
}
```

### `BootstrapOption` → table `bootstrap_options`
**Action-permissions verplaatst naar `BootstrapAction`** (review-fix). `BootstrapOption` houdt alleen catalog-eigenschappen; risk/role is per action. Option-level "effective risk" wordt server-side afgeleid (`MAX(action.risk_level) FOR action IN option.actions`) maar niet als kolom opgeslagen — alleen runtime-computed waar nodig.

```prisma
model BootstrapOption {
  id              String   @id @default(cuid())
  category_id     String
  category        BootstrapCategory @relation(fields: [category_id], references: [id], onDelete: Cascade)
  slug            String
  label           String
  description     String?
  is_default      Boolean  @default(false)
  display_order   Int
  archived        Boolean  @default(false)
  enabled         Boolean  @default(true)
  actions         BootstrapAction[]
  @@unique([category_id, slug])
  @@index([category_id, display_order])
  @@map("bootstrap_options")
}
```

### `BootstrapAction` → table `bootstrap_actions`
```prisma
model BootstrapAction {
  id                 String   @id @default(cuid())
  option_id          String
  option             BootstrapOption @relation(fields: [option_id], references: [id], onDelete: Cascade)
  kind               BootstrapActionKind
  params             Json     // Zod-validated per kind
  execution_order    Int
  supports_dry_run   Boolean  @default(true)
  side_effects       SideEffect[]   // FILESYSTEM | GITHUB_REPO | GITHUB_SETTINGS | NETWORK
  risk_level         RiskLevel    @default(LOW)         // verplaatst van Option
  requires_role      RoleRequired @default(ANY)         // verplaatst van Option
  @@index([option_id, execution_order])
  @@map("bootstrap_actions")
}
```

Action-permissions worden bij start-action gevalideerd: `RUN_BASH_TEMPLATE` action met `requires_role=ADMIN` blokkeert non-admin users in `startBootstrapAction`. Dry-run-validatie idem (Backstage-pattern).

**`condition` veld weggelaten uit MVP** — mini-DSL in fase-2.

### `BootstrapRun` → table `bootstrap_runs`
**GitHub-side-effect checkpoints toegevoegd** (review-fix) — durable record van externe mutaties zodat crash-recovery weet wat opgeruimd moet worden.

```prisma
model BootstrapRun {
  id                       String   @id @default(cuid())
  product_id               String
  product                  Product  @relation(name: "ProductBootstrapRuns", fields: [product_id], references: [id], onDelete: Cascade)
  user_id                  String
  user                     User     @relation(fields: [user_id], references: [id])
  claude_job_id            String?  @unique
  claude_job               ClaudeJob? @relation(name: "BootstrapRunJob", fields: [claude_job_id], references: [id], onDelete: SetNull)
  status                   BootstrapRunStatus  // PENDING | RUNNING | SUCCEEDED | FAILED | CANCELLED | FAILED_NEEDS_CLEANUP
  template_version         String
  template_source_sha      String?
  catalog_version          String
  recipe_hash              String
  action_schema_version    String
  repo_owner_snapshot      String
  repo_slug_snapshot       String
  selected_options         Json
  recipe_snapshot          Json
  dry_run_report           Json?

  // GitHub side-effect checkpoints (durable mutaties)
  github_repo_created_at   DateTime?
  github_repo_id           BigInt?      // GitHub's numeric repo id — JSON.stringify() gooit TypeError; cast via .toString() of Number() bij API-serialisatie
  github_repo_full_name    String?      // 'owner/repo'
  push_completed_at        DateTime?

  repo_url                 String?      // gevuld na push_completed_at
  started_at               DateTime?
  finished_at              DateTime?
  error                    String?  @db.VarChar(8192)
  output_log               String?
  created_at               DateTime @default(now())

  @@index([product_id, status])
  @@index([user_id, created_at])
  @@index([status, finished_at])      // voor stale-recovery sweep
  @@map("bootstrap_runs")
}

enum BootstrapRunStatus {
  PENDING
  RUNNING
  SUCCEEDED
  FAILED                  // recoverable / no orphan side-effects
  FAILED_NEEDS_CLEANUP    // orphan GitHub-repo or partial push; manual or compensating
  CANCELLED
}
```

**Partial unique index** (raw SQL toegevoegd in migration):
```sql
CREATE UNIQUE INDEX bootstrap_runs_one_active_per_product
  ON bootstrap_runs (product_id)
  WHERE status IN ('PENDING','RUNNING');
```

### `Product` uitbreiding (table `products`, `@@map` blijft)
```prisma
model Product {
  // ... bestaande velden ...
  repo_owner              String?
  repo_slug               String?
  template_version        String?
  last_bootstrap_run_id   String?
  last_bootstrap_run      BootstrapRun? @relation(name: "ProductLastBootstrapRun", fields: [last_bootstrap_run_id], references: [id], onDelete: SetNull)
  bootstrap_runs          BootstrapRun[] @relation(name: "ProductBootstrapRuns")   // history
  @@unique([repo_owner, repo_slug])
  // ... bestaande @@map("products") ...
}
```

Twee expliciete relaties met disjoint relation-names: `ProductLastBootstrapRun` voor de huidige pointer (SetNull bij delete van de run), `ProductBootstrapRuns` voor de history (Cascade bij delete van de product). Prisma vereist named relations bij meerdere relaties tussen dezelfde modellen.

### `User` uitbreiding (table `users`)
```prisma
model User {
  // ... bestaande velden ...
  github_pat_encrypted    String?              // prefix 'v1:<base64>'
  github_username         String?
  github_pat_verified_at  DateTime?
  github_pat_scopes       String[]  @default([])  // default-array voor migration-safety op bestaande users
  github_pat_expires_at   DateTime?
  github_orgs             Json?
  bootstrap_runs          BootstrapRun[]
  // ... bestaande @@map("users") ...
}
```

`@default([])` zorgt dat de migration op een bestaande database met users geen backfill nodig heeft; bestaande rijen krijgen een lege array.

### `ClaudeJob` uitbreiding (table `claude_jobs`)
```prisma
model ClaudeJob {
  // ... bestaande velden, inclusief lease_until + claimed_by_token_id ...
  claimed_by_worker_id    String?       // NIEUW: voor bootstrap-service (en toekomstige worker-types)
  bootstrap_run           BootstrapRun? @relation(name: "BootstrapRunJob")
  // ... bestaande @@map("claude_jobs") ...
}
```

`claimed_by_worker_id` blijft naast `claimed_by_token_id` (bestaand): laatste is voor ApiToken-claim door de Claude-CLI runner, eerste is een free-form service-instance-identifier voor `bootstrap-service`. Geen FK; pure log/correlation-veld.

`ClaudeJobKind` enum: `BOOTSTRAP_REPO` toegevoegd.

### Env (`lib/env.ts`)
- App: `BOOTSTRAP_ENCRYPTION_KEY` (required, min 32), `BOOTSTRAP_TEMPLATE_REPO` (default `madhura68/nextjs-baseline`)

---

## PAT-secret-boundary

**`startBootstrapAction` decrypt nooit**:
- Bewaart geen plaintext PAT
- Geeft alleen `runId` mee aan executie

**`bootstrap-service` decrypt per run** binnen execution-scope:
```ts
const run = await prisma.bootstrapRun.findUnique({ where:{ id: runId }, include:{ user: true }})
let pat = decryptPat(run.user.github_pat_encrypted, env.BOOTSTRAP_ENCRYPTION_KEY)
try { await executeRecipe(run, pat) }
finally { pat = ''; /* GC */ }
```

**Test-flow voor PAT** (`saveGitHubPatAction`) — **classic PAT in MVP**:
```ts
const octokit = new Octokit({ auth: token })
const { data: me, headers } = await octokit.rest.users.getAuthenticated()
const scopes = (headers['x-oauth-scopes'] ?? '').split(',').map(s => s.trim()).filter(Boolean)
if (!scopes.includes('repo')) {
  throw new Error('Classic PAT met scope "repo" vereist. Fine-grained PATs nog niet ondersteund — zie open punten.')
}
// Encrypt + opslaan + verified_at/scopes
```

**Fine-grained PATs werken anders** — geen `x-oauth-scopes` header, wel `x-accepted-github-permissions` of repository-permission-set via `GET /user/installations`. Voor MVP **alleen classic PAT ondersteund**; settings-UI toont dit expliciet ("Vereist een classic PAT met `repo` scope — fine-grained tokens nog niet ondersteund."). Fine-grained support staat in open punten.

---

## Dry-run / preview (eerste-klas)

> **Implementatienoot**: `previewBootstrapAction` doet een git clone + recipe-run (~2-5s). Als dit te zwaar wordt voor een Next.js Server Action (Vercel function geheugendruk bij hoog volume), migreer naar een Route Handler (`POST /api/bootstrap/preview`) met streaming response. In MVP is Server Action acceptabel; monitor Vercel function-metrics.

`previewBootstrapAction(productId, selections, repoOwner, repoSlug)`:
1. Auth + demo-check (403)
2. Zod-validate selections + GitHub-name regex
3. Resolve recipe + compute `recipe_hash` + `catalog_version`
4. Spin up tmpdir + clone template (geen cache in MVP — zie Template-cache-sectie)
5. Run alle handlers met `supports_dry_run=true` tegen tmpdir; `RUN_BASH_TEMPLATE` logged als "skipped"
6. Octokit preflight: `octokit.repos.get({ owner, repo })` om collision te detecteren
7. Octokit preflight: `octokit.orgs.list...` om owner-rechten te valideren
8. **File-tree filter en cap** (zie hieronder)
9. Retourneer `DryRunReport`:
   ```ts
   { fileTree: string[]; truncated: boolean; actionLog: Array<{ kind, summary, status }>; warnings: string[]; canProceed: boolean; collisions: { owner: string; slug: string } | null }
   ```

Geen DB-write, geen GitHub-write. Wel telemetry.

### Org-owner preflight — **best-effort discovery** (geen harde create-permission proof)

De wizard heeft drie verschillende garanties op verschillende plekken; **alleen de service-side `octokit.repos.create*`-call is de finale autorisatie**. Eerdere checks zijn best-effort hints, niet bewijzen.

**Stap 1: Best-effort owner-discovery** (in `RepoOwnerPicker`)

```ts
// Voor user-owner: altijd tonen indien PAT geldig
await octokit.users.getAuthenticated()              // moet slagen voor PAT-validiteit

// Voor elke org uit github_orgs cache: best-effort metadata-fetch
try {
  const { data: org } = await octokit.orgs.get({ org: orgLogin })
  return {
    login: orgLogin,
    member_status: 'visible',
    members_can_create_private: org.members_can_create_private_repositories ?? null,    // null = onbekend
    members_creation_type: org.members_allowed_repository_creation_type ?? null,
  }
} catch (err) {
  // 404 / 403 = onbekend, niet automatisch verbergen
  return { login: orgLogin, member_status: 'unknown' }
}
```

De UI toont **alle** zichtbare orgs (plus user) met een hint-badge: "✓ kan repos maken" / "⚠ org-policy onbekend" / "⚠ org-policy blokkeert private repos". Geen owner wordt **automatisch verborgen** op basis van twijfelachtige info — dat zou false negatives (legitieme orgs verborgen) creëren.

**Stap 2: Collision-check** vóór wizard-submit

```ts
try {
  await octokit.repos.get({ owner, repo })   // 200 = collision; 404 = vrij
  return { canProceed: false, collision: { owner, repo }}
} catch (err) {
  if (err.status === 404) return { canProceed: true }
  return { canProceed: false, reason: 'preflight-failed' }
}
```

**Stap 3: Finale autorisatie** = de werkelijke `octokit.repos.createForAuthenticatedUser`/`createInOrg` in `bootstrap-service`. GitHub-antwoord is de waarheid:
- `201 Created` → succes, ga door
- `403 Forbidden` → vertaal naar wizard-fout "Geen rechten om repo te maken in `<owner>`. Mogelijke oorzaken: SSO niet geautoriseerd, org-policy blokkeert, of PAT mist scope. [Wijzig owner] [Wijzig PAT]"
- `422 Unprocessable Entity` → typisch naam-conflict (race vs. stap 2); zelfde wizard-fout met "naam al in gebruik"
- Andere fouten → generieke "GitHub-fout: <message>" + retry-knop

**Documenteer dit expliciet in `RepoOwnerPicker`-tooltip**: "Owners worden best-effort gedetecteerd. Sommige org-policies (SSO, admin-only-repo-create) zijn niet vooraf zichtbaar; de daadwerkelijke create-actie is de finale check."

Scope `repo` blijft een **noodzakelijke** voorwaarde (gecheckt bij `saveGitHubPatAction`), maar de UI gebruikt geen scope-alleen heuristiek om owners te verbergen.

### File-tree scope (filter + cap)

`fileTree` is **gefilterd** anders wordt het onleesbaar (~200+ files in een Next.js-template).

Ignore-patterns (gitignore-stijl):
```
.git/
node_modules/
.next/
dist/  build/  out/
*.log
.DS_Store
.env*
coverage/
```

**Hard cap** van 500 entries; bij overschrijding `truncated: true` met indicator `[+12 more files omitted]`. MVP: flat `string[]`. Fase-2 kan migreren naar:
```ts
type DryRunFileTreeNode = { name: string; type: 'file' | 'dir'; children?: DryRunFileTreeNode[]; size?: number }
```

### Template-cache lifecycle

**MVP: geen cache** — `git clone --depth=1` met `isomorphic-git` is snel (~2-3s op kleine templates); cache-complexity is YAGNI tot het pijn doet.

**Fase-2 strategie** (als preview-latency UX-probleem wordt):
- Disk-cache in `/var/cache/bootstrap-service/<template_repo_slug>/<template_version>/`
- Persistent across service-restarts (snelle warm-start)
- **Niet** clearen op deploy (deploys frequent, cache-warmup duur)
- **Geen invalidation nodig** voor tag-pinned versies (semver-tags zijn immutable; re-tag is mis-use)
- TTL-sweep via `last_used.json` mark-file per cached version; cron verwijdert versies >30 dagen ongebruikt

---

## Catalog/recipe versioning

### Hash-input bepaling (determinisme)

`recipe_hash` wordt berekend over **`recipe_snapshot`** (de resolved action-list), **niet** over `selected_options`. Reden: identieke `selected_options` met een andere catalog-versie produceert andere acties → andere uitkomst; de hash moet dat onderscheid tonen. `catalog_version` blijft een orthogonaal apart veld.

Canonicalization-regels (in `packages/bootstrap-actions/recipe-hash.ts`):
```ts
function canonicalize(recipe: RecipeSnapshot): string {
  const sorted = {
    actions: recipe.actions
      .sort((a, b) => a.execution_order - b.execution_order)
      .map(a => ({
        kind: a.kind,
        execution_order: a.execution_order,
        params: sortObjectKeysRecursive(a.params),
      })),
  }
  return JSON.stringify(sorted)
}
export function recipeHash(recipe: RecipeSnapshot): string {
  return createHash('sha256').update(canonicalize(recipe)).digest('hex')
}
```

Geen timestamps, geen UUIDs in de hash-input. Identieke recipe ⇔ identieke hash, garandeert deterministische replay.

### Velden op `BootstrapRun`

- `recipe_hash` = sha256(canonicalize(recipe_snapshot))
- `catalog_version` = sha256(canonicalize(catalog_snapshot)) — zelfde discipline als recipe_hash
- `action_schema_version` = hardcoded in shared package; bumped bij schema-breaks
- `template_source_sha` = git SHA na clone

### Deterministische `catalog_version` berekening

Niet `md5(string_agg(...))` — dat is zonder ordering niet deterministisch en mist categories/actions. Gebruik dezelfde canonical-JSON-aanpak als `recipe_hash`:

```ts
// In packages/bootstrap-actions/catalog-hash.ts
export function catalogVersion(catalog: CatalogSnapshot): string {
  const sorted = {
    categories: catalog.categories
      .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug))
      .map(cat => ({
        slug: cat.slug,
        selection_type: cat.selection_type,
        is_required: cat.is_required,
        display_order: cat.display_order,
        options: cat.options
          .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug))
          .map(opt => ({
            slug: opt.slug,
            is_default: opt.is_default,
            enabled: opt.enabled,
            archived: opt.archived,
            display_order: opt.display_order,
            actions: opt.actions
              .sort((a, b) => a.execution_order - b.execution_order || a.id.localeCompare(b.id))
              .map(act => ({
                kind: act.kind,
                execution_order: act.execution_order,
                params: sortObjectKeysRecursive(act.params),
                supports_dry_run: act.supports_dry_run,
                side_effects: [...act.side_effects].sort(),
                risk_level: act.risk_level,
                requires_role: act.requires_role,
              })),
          })),
      })),
  }
  return createHash('sha256').update(JSON.stringify(sorted)).digest('hex')
}
```

Wijzigingen in **elk** van deze velden (selection_type, required/default, enabled/archived, action kind, params, dry-run support, side effects, risk_level, requires_role) leveren een nieuwe `catalog_version`. Geen md5; sha256 voor consistentie met `recipe_hash`. SQL-loading: `SELECT * FROM bootstrap_categories WHERE archived=false ORDER BY display_order, slug` + nested fetches; transformatie in TypeScript via `catalogVersion()`.

### `.scrum4me/bootstrap.json` in target-repo (na succes)
```json
{ "template_repo": "...", "template_version": "v1.0.0", "template_source_sha": "...",
  "catalog_version": "...", "recipe_hash": "...", "action_schema_version": "1.0",
  "generated_at": "...", "selected_options": {...} }
```

---

## UI-componenten (`app/(app)/...`)

| Component | Locatie |
|---|---|
| `BootstrapWizardDialog` | `app/(app)/products/[id]/_components/bootstrap-wizard-dialog.tsx` |
| `BootstrapWizardStep` | idem (per-categorie) |
| `RepoOwnerPicker` | idem |
| `BootstrapPreviewPanel` | idem |
| `BootstrapStatusPanel` | idem (SSE-status) |
| `GitHubPatSettings` | `app/(app)/settings/_components/github-pat-settings.tsx` |
| `BootstrapAdminPage` (fase-2) | `app/(app)/admin/bootstrap/page.tsx` |
| Product-detail-knop | `app/(app)/products/[id]/page.tsx` |

Wizard-flow: **Configure → Preview → Run**.

---

## Server-actions (`actions/bootstrap.ts`)

- `previewBootstrapAction` → `DryRunReport` (**Sprint 1c**)
- `startBootstrapAction` → `{ runId }`; gebruikt partial unique index voor concurrency (**Sprint 1c**)
- `cancelBootstrapAction(runId)` → markeert `ClaudeJob.status='CANCELLED'`; service detecteert per-action (**Fase 2** — stub in MVP, geen UI-knop)
- `retryBootstrapAction(failedRunId)` → nieuwe run met zelfde selections (**Fase 2**)
- `saveGitHubPatAction(token)` → encrypt + verify + scope-detect + opslaan (**Sprint 1c**)

Alle vijf: demo-check (403) + Zod + rate-limit. In MVP worden `cancelBootstrapAction` en `retryBootstrapAction` als gated stub geïmplementeerd (403 of 501) maar nog niet aangeroepen via UI.

Atomisch enqueue via **transaction callback** — Prisma genereert beide cuid's intern, geen externe `cuid2`-dependency nodig:

```ts
import { jobStatusToApi } from '@/lib/job-status'

const { jobId, runId } = await prisma.$transaction(async (tx) => {
  const job = await tx.claudeJob.create({
    data: {
      kind: 'BOOTSTRAP_REPO',
      status: 'QUEUED',
      user_id: userId,
      product_id: productId,
      // requested_* allemaal null (deterministic runtime)
    },
    select: { id: true },
  })
  const run = await tx.bootstrapRun.create({
    data: {
      product_id: productId,
      user_id: userId,
      claude_job_id: job.id,
      status: 'PENDING',
      template_version,
      catalog_version,
      recipe_hash,
      action_schema_version,
      repo_owner_snapshot,
      repo_slug_snapshot,
      selected_options,
      recipe_snapshot,
    },
    select: { id: true },
  })
  return { jobId: job.id, runId: run.id }
})

// NA commit (niet IN transaction):
await prisma.$executeRaw`
  SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
    type: 'claude_job_enqueued',
    job_id: jobId,
    user_id: userId,
    kind: 'BOOTSTRAP_REPO',
    status: jobStatusToApi('QUEUED'),    // 'queued' lowercase
    bootstrap_run_id: runId,
  })}::text)
`

return { runId }
```

Concurrency wordt afgedwongen door partial unique index — een tweede gelijktijdige insert mislukt met unique violation. Geen externe ID-library nodig; Prisma's built-in `cuid()` default doet het werk.

---

## Shared action-package (in **Scrum4Me-repo**)

**Locatie**: `packages/bootstrap-actions/` **binnen de Scrum4Me-repo** (review-fix). Reden: dit package bevat geen secrets, alleen Zod-schema's en pure-JS file-handlers. Het mag dus mee in de Scrum4Me build — een `file:` link naar een sibling-directory zou de app onbouwbaar maken in Vercel/CI (sibling zit niet in de checkout).

**Inhoud**:
- `schema.ts` — `ActionSchema` (Zod discriminated union)
- `handlers/{copy-file,write-file,append-to-file,replace-string,create-adr-stub,add-dependency,run-bash}.ts`
- `allowed-commands.ts` — RUN_BASH_TEMPLATE regex-array
- `recipe-hash.ts` — canonicalize + sha256
- `types.ts` — gedeelde DryRunReport, ActionContext, etc.
- `package.json` met `"name": "@scrum4me/bootstrap-actions"`, `"version": "0.1.0"`

**Consumers**:
- Scrum4Me-app: `lib/bootstrap/dry-run.ts` importeert `@scrum4me/bootstrap-actions` via workspace-resolution
- `bootstrap-service/src/runner.ts`: idem; consumeert via release (zie hieronder)

**Hoe Scrum4Me-app het package gebruikt**:
- Als de Scrum4Me-repo één package.json heeft (geen workspaces): bouw als pure TS-bibliotheek in `packages/bootstrap-actions/` en consumeer via path-alias of monorepo-light setup
- Als workspaces wel beschikbaar zijn: voeg `"workspaces": ["packages/*"]` toe aan root `package.json` (minimal pnpm/npm workspaces; geen Turborepo nodig)
- MVP-pragmatisch: TypeScript path-alias `@scrum4me/bootstrap-actions` → `./packages/bootstrap-actions/src` in `tsconfig.json`, bundlen via Next.js default. **Let op**: ook `vitest.config.ts` krijgt `resolve.alias: { '@scrum4me/bootstrap-actions': './packages/bootstrap-actions/src' }` anders falen unit-tests op dit package.

**Hoe bootstrap-service het consumeert**:
- **Optie A (MVP)**: `bootstrap-service` doet bij iedere release een **vendor-copy** van het package via een sync-script (`scripts/sync-bootstrap-actions.sh` — vergelijkbaar met `sync-schema.sh`). Sources blijven in Scrum4Me-repo authoritative; service kopieert een snapshot bij CI-build.
- **Optie B (later)**: publiceer naar GitHub Packages (`@madhura68/bootstrap-actions`) zodra een derde consumer of coordination-pijn dit rechtvaardigt.

**Upgrade-trigger naar B** (geen sprint-automatisme — pas wanneer):
1. Derde consumer verschijnt (CI-pipeline, tweede service, externe contributor)
2. Coordination-pijn: `ActionSchema`-wijziging die app/service apart-deploy verbreekt zonder version-pin
3. Externe gebruiker vraagt het package buiten lokale dev

---

## Action-schema + path-safety

```ts
// packages/bootstrap-actions/src/schema.ts
const SafeRelPath = z.string().min(1).max(256)
  .regex(/^[A-Za-z0-9_./-]+$/)
  .refine(p => !p.startsWith('/'), 'absolute denied')
  .refine(p => !p.split('/').includes('..'), 'parent traversal denied')
  .refine(p => !p.split('/').includes('.git'), '.git denied')

export const ActionSchema = z.discriminatedUnion('kind', [
  z.object({ kind: z.literal('COPY_FILE'),       params: z.object({ source: SafeRelPath, dest: SafeRelPath })}),
  z.object({ kind: z.literal('WRITE_FILE'),      params: z.object({ path: SafeRelPath, content: z.string().max(65_536) })}),
  z.object({ kind: z.literal('APPEND_TO_FILE'),  params: z.object({ path: SafeRelPath, content: z.string().max(65_536), marker: z.string().min(1).max(256) })}),
  z.object({ kind: z.literal('REPLACE_STRING'),  params: z.object({ file: SafeRelPath, find: z.string().min(1).max(1024), replace: z.string().max(8192) })}),
  z.object({ kind: z.literal('CREATE_ADR_STUB'), params: z.object({ number: z.number().int().min(1).max(99), title: z.string().min(1).max(160), template: z.enum(['nygard','madr']) })}),
  // MVP: alleen exact/range semver (geen 'latest', 'workspace:*', 'npm:'-aliases, geen prerelease-labels).
  // Fase-2: vervang regex door npm-package-arg + allowlist van toegestane spec-types.
  z.object({ kind: z.literal('ADD_DEPENDENCY'),  params: z.object({ name: z.string().regex(/^[@a-z0-9/_.-]+$/), version: z.string().regex(/^[\^~>=<.\d-]+$/), dev: z.boolean() })}),
  z.object({ kind: z.literal('RUN_BASH_TEMPLATE'), params: z.object({ command: z.string().refine(c => allowedCommands.some(rx => rx.test(c))) })}),
])
```

Path-resolution in handlers: `path.resolve(tmpdir, params.dest)` + assert `result.startsWith(tmpdir + path.sep)`.

**Run-level caps**:
- ≤ 200 acties per recipe
- ≤ 256 KiB output_log
- 30-min runtime watchdog
- ≤ 64 KiB per WRITE/APPEND content
- ≤ 50 MB tmpdir total

---

## Enum-uitbreiding — exhaustive scope

`ClaudeJobKind.BOOTSTRAP_REPO` ripple:
- `components/jobs/job-card.tsx` (`Record<ClaudeJobKind, …>`)
- `components/jobs/jobs-column.tsx`
- `lib/insights/agent-throughput.ts`
- SSE initial-payload + filter-set
- `lib/job-config.ts` + spiegel-MCP: discriminated union returns `runtime: 'deterministic'`
- Tests rond exhaustive switches

---

## Bestanden te wijzigen / aan te maken

### Database (Scrum4Me)
- `prisma/schema.prisma` — modellen + enums + `@@map` annotaties + Product/User uitbreidingen
- `prisma/migrations/<ts>_bootstrap_wizard/migration.sql` — incl. partial unique index (raw SQL)
- `prisma/seed.ts` — `seedBootstrapCatalog()` met 6 core + add-ons
- `scrum4me-mcp/prisma/schema.prisma` — gesynced via `sync-schema.sh`
- `~/Development/bootstrap-service/prisma/schema.prisma` — gesynced idem

### App-side (Scrum4Me)
- `lib/env.ts` — `BOOTSTRAP_ENCRYPTION_KEY`, `BOOTSTRAP_TEMPLATE_REPO`
- `lib/job-config.ts` (+ MCP-spiegel) — discriminated union, BOOTSTRAP_REPO → deterministic
- `lib/crypto/pat.ts` — encrypt only (decrypt leeft in service)
- `actions/bootstrap.ts` — preview/start/cancel/retry/savePat
- `actions/products.ts` — `repo_owner`, `repo_slug` velden
- `lib/bootstrap/dry-run.ts` — gebruikt shared handlers
- `lib/bootstrap/recipe.ts` — recipe-resolver van selections naar action-list

### Shared package (in deze repo)
- `packages/bootstrap-actions/` — schema, handlers, types, recipe-hash, allowed-commands. Path-alias `@scrum4me/bootstrap-actions` in `tsconfig.json`
- `scripts/sync-bootstrap-actions.sh` — sync-script (analoog aan `sync-schema.sh`) dat het package naar `~/Development/bootstrap-service/packages/bootstrap-actions/` vendor-copyt bij service-build

### Jobs-board (Scrum4Me)
- `components/jobs/job-card.tsx`, `jobs-column.tsx`
- `lib/insights/agent-throughput.ts`
- SSE-routes (`app/api/realtime/jobs/route.ts` etc.)

### UI (Scrum4Me)
- `app/(app)/products/[id]/page.tsx` + `_components/*`
- `app/(app)/settings/_components/github-pat-settings.tsx`
- `app/(app)/admin/bootstrap/page.tsx` (fase-2)

### Worker (scrum4me-docker)
- `bin/run-one-job.ts` — skip-filter `AND kind <> 'BOOTSTRAP_REPO'`
- `docs/manual/05-docker.md` — herbevestig secret-boundary

### Nieuwe sibling-repo (`~/Development/bootstrap-service/`)
```
bootstrap-service/
├─ package.json                         # deps: prisma, isomorphic-git, @octokit/rest, zod
├─ tsconfig.json
├─ env.ts                               # service-env Zod-schema
├─ bin/run.ts                           # daemon: LISTEN + claim-loop + lease-renewal
├─ src/
│  ├─ claim.ts                          # tryClaim, releaseClaim, renewLease (lease_until)
│  ├─ runner.ts                         # executeRecipe(run, pat)
│  ├─ template-clone.ts                 # isomorphic-git clone --depth=1 --tag
│  ├─ github.ts                         # Octokit wrapper + isomorphic-git push (onAuth)
│  ├─ status-sync.ts                    # transactionele update + post-commit pg_notify
│  ├─ crypto/pat.ts                     # decrypt
│  ├─ telemetry.ts                      # log-helper met token-scrubbing
│  └─ stale-recovery.ts                 # lease_until < NOW recovery
├─ prisma/schema.prisma                 # gesynced
├─ packages/bootstrap-actions/          # shared package (zie boven)
├─ Dockerfile                           # multi-arch, Mac arm64 primary
├─ docker-compose.yml                   # default target arm64 lokale Mac dev
├─ sync-schema.sh                       # zelfde patroon als scrum4me-mcp
└─ README.md
```

**Deployment target** (volgt Scrum4Me-pattern uit memory: "Docker deploy = Mac default; scrum4me-docker arm64 native; NAS-flow opt-in"):
- `Dockerfile` start met `FROM --platform=$BUILDPLATFORM node:24-alpine`
- Build met `docker buildx build --platform linux/amd64,linux/arm64 --push`
- `docker-compose.yml` default target arm64 voor lokale Mac dev
- CI publiceert beide platforms naar GitHub Container Registry (`ghcr.io/madhura68/bootstrap-service`)
- Geen platform-specifieke deps — `isomorphic-git`, `@octokit/rest` zijn pure-JS; Prisma client multi-arch native
- NAS/Linux-deploy (Beelink server etc.) is opt-in via `--platform=linux/amd64`

### ADR + docs (Scrum4Me)
- `docs/adr/0009-bootstrap-wizard.md` (Nygard, accepted)
- `docs/architecture/bootstrap-service.md`
- `docs/runbooks/bootstrap-wizard.md` — operatorpad incl. PAT-setup, troubleshooting, FAILED_NEEDS_CLEANUP handling
- `docs/INDEX.md` regenereren

### Env + deployment docs
- `.env.example` (in Scrum4Me-repo) — voeg `BOOTSTRAP_ENCRYPTION_KEY` en `BOOTSTRAP_TEMPLATE_REPO` toe met instructies hoe te genereren (`openssl rand -base64 32`)
- `~/Development/bootstrap-service/.env.example` — `DATABASE_URL`, `DIRECT_URL`, `BOOTSTRAP_ENCRYPTION_KEY` (gedeeld met app)
- `~/Development/bootstrap-service/README.md` — setup-instructies (clone, sync-schema, npm install, env, npm run dev)
- `docs/manual/06-bootstrap-service.md` — deployment-runbook voor productie (Mac arm64 default, Linux opt-in)

### Externe template-repo
- Apart traject: `madhura68/nextjs-baseline` v1.0.0 — geen wijzigingen in deze PR

---

## Fasering

**Sprint 1a — Contracten**
1. ADR-0009
2. `JobConfig` discriminated union + tests
3. `scrum4me-docker/bin/run-one-job.ts` skip-filter + tests
4. Shared `bootstrap-actions` package scaffold (schema + handler-interfaces)
5. `lib/bootstrap/notify.ts` post-commit pg_notify helper + tests
6. **Schema-hash drift-check** (vendor-copy mitigatie): `scripts/check-bootstrap-actions-hash.sh` berekent `sha256` over `packages/bootstrap-actions/src/**` en vergelijkt met `~/Development/bootstrap-service/packages/bootstrap-actions/.hash`. CI faalt bij mismatch. Service logt bij startup geladen hash + `action_schema_version`.

**Sprint 1b — Schema + seed + safety**
6. Prisma-modellen (incl. `@@map`) + migration (incl. partial unique index)
7. Action-handlers in shared package (idempotent, path-safe)
8. Seed met 6 core categorieën + minimale add-ons
9. Jobs-board enum-uitbreidingen

**Sprint 1c — PAT + Dry-run**
10. `lib/crypto/pat.ts` (encrypt) + tests
11. `GitHubPatSettings` UI + `saveGitHubPatAction` (scope-detect + verify)
12. `previewBootstrapAction` + `lib/bootstrap/dry-run.ts`
13. Wizard: Configure + Preview steps

**Sprint 1d — bootstrap-service + E2E**
14. Sibling-repo `bootstrap-service/` opzetten: package.json, env, sync-schema.sh, sync-bootstrap-actions.sh, Dockerfile
15. Claim-loop + LISTEN + lease-renewal (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`)
16. Execute-flow: isomorphic-git clone + recipe + Octokit createRepo + isomorphic-git push + side-effect checkpoints
17. Status-sync transactional + post-commit NOTIFY (lowercase status)
18. **Stale-recovery cron** `/api/cron/bootstrap-stale-recovery` met split-strategie (`FAILED` vs `FAILED_NEEDS_CLEANUP`) — MVP, niet fase-2
19. **Service-startup logging**: print `action_schema_version`, schema-hash, en geladen catalog-version voor observability/drift-detectie
20. `BootstrapStatusPanel` realtime SSE
21. E2E: nieuw product → wizard → preview → run → SUCCEEDED → repo bestaat
22. **Drift-verificatie-stap**: CI-job die `check-bootstrap-actions-hash.sh` draait na elke bootstrap-service-image-build; release-pipeline faalt bij mismatch met Scrum4Me-bron

**Fase 2**:
- Add-ons (sentry, web-push, realtime, demo-policy, MD3-theme)
- `RUN_BASH_TEMPLATE` met allowlist
- `condition` mini-DSL
- `retryBootstrapAction` + `cancelBootstrapAction`
- Admin-CRUD UI + catalog-publish met dry-run-gate
- Update-detection
- `FAILED_NEEDS_CLEANUP` admin-UI met handmatige "Cleanup orphan repo"-knop

**Fase 3**:
- Sandbox-isolatie per run
- GitHub App (van per-user PAT naar app-installation)
- Multi-tenant cross-org owners
- GitHub-webhook back-channel

---

## Verificatie

```bash
# 1. Contracten
npm test -- lib/job-config
npm test -- lib/bootstrap/notify
npm test -- packages/bootstrap-actions/schema

# 2. Schema (alle tables snake_case)
npx prisma migrate dev
npm run seed
psql "$DATABASE_URL" -c "\dt"   # toont bootstrap_categories, bootstrap_options, bootstrap_actions, bootstrap_runs
psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_categories"    # 7
psql "$DATABASE_URL" -c "SELECT slug FROM bootstrap_categories WHERE is_required = true ORDER BY display_order"
# Expect: deploy, auth, database, ui-components, state-management, testing

# 3. PAT
# - Settings → plak PAT met repo-scope → Test toont "✓ <username>" + scopes
psql "$DATABASE_URL" -c "SELECT length(github_pat_encrypted), github_pat_scopes FROM users WHERE id=X"
# Expect: lengte > 100; scopes = ['repo']

# 4. Dry-run
# - Wizard configure → Preview-stap → DryRunReport file-tree zichtbaar
# - Geen DB-row bij bootstrap_runs aangemaakt
# - Geen GitHub-write-call (alleen GET /repos/{owner}/{repo})

# 5. End-to-end
# Maak product 'ops-dashboard'; wizard alle 6 core; repo_owner=madhura68; preview groen; Run
# Service claimt binnen 2s; lease_until wordt verlengd; status SUCCEEDED in <60s
psql "$DATABASE_URL" -c "
  SELECT br.status,
         br.repo_url,
         br.recipe_hash,
         cj.lease_until > NOW() AS lease_active
  FROM bootstrap_runs br
  JOIN claude_jobs cj ON cj.id = br.claude_job_id
  ORDER BY br.started_at DESC NULLS LAST, br.created_at DESC
  LIMIT 1
"
# NB: lease_until staat op claude_jobs, niet bootstrap_runs — JOIN nodig
psql "$DATABASE_URL" -c "
  SELECT status, finished_at FROM claude_jobs
  WHERE kind = 'BOOTSTRAP_REPO' ORDER BY created_at DESC LIMIT 1
"
# Expect: DONE, finished_at gevuld
gh api repos/madhura68/ops-dashboard/contents/.scrum4me/bootstrap.json | jq .
# Expect: { template_version: 'v1.0.0', recipe_hash: ..., selected_options: {...} }
gh api repos/madhura68/ops-dashboard/contents/docs/adr | jq '.[].name'
# Expect: 0000…0006-... (alle 6 stubs)

# 6. Failure
# Invalid PAT → bootstrap → FAILED met "Bad credentials" — geen orphan repo
psql "$DATABASE_URL" -c "SELECT status, error FROM bootstrap_runs WHERE status='FAILED' ORDER BY finished_at DESC LIMIT 1"

# 7. Concurrency
# Twee gelijktijdige starts → één gaat door, andere unique violation
psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_runs WHERE product_id=X AND status IN ('PENDING','RUNNING')"
# Expect: 1

# 8. Demo-policy
# Login als demo → Bootstrap-knop niet zichtbaar; direct API call → 403

# 9. Lease-renewal
# Tijdens RUNNING-fase: verifieer dat lease_until > NOW + 30s
psql "$DATABASE_URL" -c "SELECT lease_until - NOW() FROM claude_jobs WHERE status='RUNNING'"

# 10. Stale-recovery (Sprint 1d — niet fase-2)
# Kill bootstrap-service tijdens RUNNING; cron picks up na lease_until < NOW
psql "$DATABASE_URL" -c "SELECT status FROM claude_jobs WHERE id=X"
# Expect na cleanup: FAILED (geen GitHub side-effects) of FAILED_NEEDS_CLEANUP (repo al aangemaakt)

# 11. Project-acceptatie
npm run verify && npm run build
```

**Lokaal dev**:
```bash
# In Scrum4Me/:
npm run dev

# In ~/Development/bootstrap-service/ (apart terminal):
npm run dev   # of `docker compose up bootstrap-service`
```

---

## Deployment prerequisites (vóór Sprint 1d)

- `madhura68/nextjs-baseline` repo **must be public** en tag `v1.0.0` aanwezig (anders falen alle clone-ops)
- `BOOTSTRAP_ENCRYPTION_KEY` genereren: `openssl rand -base64 32` — **zelfde waarde** in Scrum4Me-app en bootstrap-service
- Prisma migration geapplied + seed gedraaid

---

## Accepted risks (bewuste keuzes)

- **PAT in JS function-closure**: `pat = ''` helpt niet bij GC van de originele immutable string — in Node.js geen betere optie zonder `Buffer`-gebruik. Accepted: PAT leeft kort (functie-scope), geen logging, geen serialisatie buiten de closure.
- **previewBootstrapAction als Server Action**: kan zware Vercel function zijn bij hoog volume — monitor in productie; migreer naar Route Handler als nodig.
- **Vendor-copy sync-drift**: als `ActionSchema` in Scrum4Me-repo wijzigt maar sync-script niet gedraaid is, silent mismatch in bootstrap-service. Mitigatie: CI-check in bootstrap-service die de schema-hash vergelijkt.

---

## Open punten (post-MVP)

- **Idempotency** voor APPEND_TO_FILE (marker-based) en RUN_BASH_TEMPLATE
- **Key rotation** voor `BOOTSTRAP_ENCRYPTION_KEY` (`v1:` prefix maakt versionering mogelijk)
- **PAT-expiry honoring** — UI-prompt als `github_pat_expires_at < now + 7d`
- **Fine-grained PAT support** — vereist andere scope-detection-logica (`x-accepted-github-permissions` of `GET /user/installations`); UI moet kunnen kiezen tussen classic en fine-grained
- **GitHub App migratie** — fase-3 alternative voor PAT (granular permissions, geen user-token-management)
- **Shared package upgrade** — van vendor-copy naar gepubliceerd GitHub Packages-package als derde consumer komt
- **`FAILED_NEEDS_CLEANUP` recovery flow** — admin-UI knop voor handmatige `octokit.repos.delete` of "mark as resolved"
- **Bootstrap-service unit-tests** — claim.ts, runner.ts, status-sync.ts hebben geen test-strategie beschreven; overweeg integration-tests met een lokale Postgres-instance
- **`ADD_DEPENDENCY.version` spec-broadening** — `npm-package-arg`-parser ondersteuning voor `latest`, prerelease (`^1.2.3-beta.1`), `workspace:*`, `npm:`-aliases, git/tarball specs; per-spec-type allowlist

---

## Plan-locatie noot

Bij implementatie-go: hernoem naar `docs/plans/M8-bootstrap-wizard.md` voor zichtbaarheid binnen Scrum4Me + MCP-Milestone-koppeling.
