Files
0pt/docs/09-build-plan.md
T
admin 328f8a46cb
CI (SIAX Cloud) / security (push) Successful in 19s
Security Scan (SIAX Cloud) / security (push) Successful in 19s
CI (SIAX Cloud) / quality (push) Successful in 44s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / quality (pull_request) Successful in 42s
docs+chore: repo hygiene -- v2.0 manifests, ADRs, changelog, doctor, guides
BACKLOG P1-1: siax.repo.json/siax.config.json rewritten to be schema-valid
against sax3l/siax-standard's actual v2.0 schemas (lifecycle,
operationalState, gitHost, domains-as-objects, consumedBy, children,
contracts -- validated with jsonschema, not just eyeballed). config.json
restructured to the real deploy-descriptor shape (framework/build/start/
port/healthcheck/environments) instead of duplicating repo.json's identity
fields, which the schema doesn't allow.

BACKLOG P1-2: 3 ADRs for decisions already made and evidenced elsewhere in
this repo (Coolify-not-Vercel, Zitadel-not-NextAuth, Iceberg-format pointer
to n0d ADR-0030) -- retroactive documentation, not new decisions. The
Stripe/Revolut and services/scrapers/* questions are explicitly NOT written
as ADRs here; both are account-owner-gated or actively under review
elsewhere (see docs/architecture/decisions/README.md).

BACKLOG P1-3: CHANGELOG.md from real git log history (no tags/releases
exist yet -- this doesn't invent version numbers that were never cut).

BACKLOG P1-19: `pnpm siax:doctor` (scripts/siax-doctor.mjs, AGENT-04) --
zero-dependency, checks required files + manifest shape, prints
control-IDs. .devcontainer/devcontainer.json (DX-03). compose.dev.yaml
(DX-02 -- UNVERIFIED, no Docker available in this environment to smoke-test,
flagged in the file itself). All 4 prompts + the index rewritten to AGENT-03
shape (goal/context/prerequisites/scope/contract/acceptance-criteria/
control-ID/forbidden-actions).

BACKLOG P1-20: docs/architecture/ARCHITECTURE.md migrated to the v2.0
9-heading template -- new §5 "Ytor och paritet" (capability x
API/CLI/MCP/console coverage table, grounded in the same registry facts
docs/02-architecture.md already established), §6-9 renumbered from the old
§5-8 (all internal cross-references fixed, CLAUDE.md's own §-numbers left
untouched). docs/api/ added (points at contracts/openapi.yaml as the one
SSOT instead of duplicating it as drift-prone prose).

BACKLOG P1-21: packages/schemas now has a real package.json (was loose JSON
files with no package -- pnpm-workspace.yaml's packages/* glob matched an
empty package). Verified `pnpm install --frozen-lockfile` and the full
typecheck/test/build suite still pass with 2 real workspace projects.
apps/ restructuring explicitly NOT attempted (BACKLOG's own effort estimate
is "L" / large -- moving the whole app tree is a bigger, riskier change than
belongs in this pass). Import-boundary check added via dependency-cruiser
(ARK-14) -- installed for real, config tested against this codebase (179
modules, 310 deps, 0 violations), wired as `pnpm run deps:check` (advisory,
not yet a CI gate).

BACKLOG P2-5: docs/02-architecture.md's "Hosting" section marked SUPERSEDED
(the Vercel plan was superseded by the Coolify deploy.target flip,
2026-08-09 -- ADR-0001). README.md/.env.example were checked and are
already accurately framed (live-on-Vercel vs. config-target-is-Coolify) --
not touched.

BACKLOG P3-5: 2 stale github.com/sax3l/0pt/pull/3 links removed (the PR
merged long ago, .env.example is real and current) -- docs/09-build-plan.md,
docs/08-infrastructure.md. The "two diverged remotes, unresolved which is
canonical" language elsewhere (CLAUDE.md, AGENTS.md, SECURITY.md, etc.) is
deliberately NOT touched -- that's a live, correctly-flagged, account-owner-
owned open question per the Gitea-exclusivity mandate, not a stale link.

BACKLOG P6 (19 items): concrete artifacts for the ones that are actually a
file (.editorconfig, dependency-cruiser, deprecation policy, K8s reference
manifest, .actrc, Makefile, ONBOARDING.md); docs/guides/P6-GUIDES.md for the
rest (CLI-13, DOC-18, UI-30, IAM-19, PAY-11, AI-18, OBS-21, TEST-20, NET-19,
SCALE-13, OSS-10, AGENT-13) -- each grounded in this repo's actual code/gaps,
not generic advice.

NOT attempted (out of this task's scope, noted for the record):
- P2-1 (cl0ud app-registry.ts mismatch) -- lives in a different repo
  (sax3l/cl0ud), not cloned/authorized for this task's single-repo PR flow.
- The CI "doctor step" half of P1-15 -- deliberately left out of this PR's
  .gitea/workflows/ci.yml to avoid a 3-way merge conflict with
  ci/security-headers-and-scanning, which already restructured that file;
  add it as a follow-up once that PR merges.

This branch was authored independently of, but shares docs/architecture/
ARCHITECTURE.md and docs/slo.md with, fix/honest-health-check and
ci/security-headers-and-scanning -- expect a small, normal merge conflict
if this is rebased after either of those merge first (both already rebased
cleanly against each other and against main as of this commit).
2026-08-11 14:18:00 +02:00

20 KiB
Raw Blame History

09 · Build plan

Phased, in dependency order. Finish a phase to its acceptance criteria before the next. Each phase = small, reviewable commits. Reconcile with existing code rather than rewriting wholesale.

Status callouts added 2026-07-19. Each phase below now carries a Status: line, grep/read-verified against main this pass. These are conservative on purpose: a phase is only called Done when this pass found real, working code satisfying its Accept criteria; anything else is Partial (with the specific evidence and gap named) or Not started. No phase is upgraded to Done without a code citation. Where a referenced doc (e.g. docs/design/...) doesn't exist, that's flagged rather than silently worked around. See docs/08-infrastructure.md for the full env/security grounding these statuses draw on.

Phase 0 — Foundation & repo

  • Monorepo structure (docs/08). Migrate existing src/orchestrator + src/workers into services/api.
  • Postgres schema + migrations (docs/03); pgvector enabled. .env.example.
  • docker-compose up (postgres, redis, api, worker, nocodb, metabase, caddy). /health green.
  • CI skeleton (lint/typecheck/build).
  • Accept: docker compose up runs the whole stack locally; migrations apply; /health returns ok for db + queue.
  • Status: Partial.
    • Repo structure exists but diverged from the apps/web/packages/shared layout docs/08 originally sketched — it settled on app/ (Next.js, repo root), lib/, src/ (platform clients/services), and services/api/ (FastAPI). docs/08-infrastructure.md now documents the structure as-built.
    • Migration to services/api/orchestrator + services/api/workers is done (verified: those directories exist and contain main.py/adaptive_router.py/pipeline.py/etc.; no src/orchestrator or src/workers remain at the old paths).
    • Postgres schema is real: sql/001_init.sql (CREATE EXTENSION vector, CREATE EXTENSION pgcrypto, 17 tables incl. orgs, users, orders, jobs, audit_log, bridge_provenance, db_grants, payments) + sql/004_iceberg_write_boundary.sql. No sql/002_* or sql/003_* exists anywhere in this repo's history — some other docs (docs/ICEBERG_WRITE_BOUNDARY.md etc.) cite a sql/002_p0_dataset_quality.sql that was never built; see docs/08-infrastructure.md's "Schema state" section.
    • .env.example — now exists and is merged to main (BACKLOG P3-5: the stale link to the unmerged github.com/sax3l/0pt/pull/3 that used to sit here has been removed — that PR has long since landed, .env.example is a real, current, gitignore-exempted file at the repo root today).
    • infra/docker-compose.yml and infra/Caddyfile are real, committed files (not sketches) covering exactly the services listed. This pass found no evidence they've ever actually been run — no CI, no deploy log, no reachable host checked. "File exists and looks correct" ≠ "verified to run"; treat the Accept criterion ("docker compose up runs... /health returns ok") as unverified.
    • CI skeleton — not built. There is no .github/workflows/ directory in this repo at all. pnpm lint/typecheck/test exist as local scripts only.

Phase 1 — Design system in code

  • Tailwind + shadcn; globals.css (grain + reveal keyframes) and useReveal verbatim (docs/05).
  • Build DataArtifact primitive (table/map/schema/chart/grid) and ProvenanceBadge (docs/design prompts 5.1, 2.11).
  • Accept: a sample page renders mono tokens, a DataArtifact animates on scroll, reduced-motion respected, focus rings visible.
  • Status: Partial — real components exist; scroll-motion/reduced-motion/focus-ring behavior not independently exercised this pass.
    • Tailwind + shadcn/radix stack: confirmed real in package.json (tailwindcss, @radix-ui/*, class-variance-authority, tailwind-merge, tailwindcss-animate).
    • hooks/use-reveal.ts, components/data-artifact.tsx, components/provenance-badge.tsx all exist and are used across app/katalog, app/kallor, app/bestall, app/order/[id], and most components/sections/* files (grep-confirmed).
    • docs/design/0pt-v0-build-brief.md and docs/design/0pt-design-coverage-and-visuals.md (the "prompts 5.1, 2.11" this bullet references) do not exist anywhere in this repo — confirmed via full-repo search; there is no docs/design/ directory at all. CLAUDE.md's own doc index has already been corrected to flag this as a gap rather than list them as real. Treat any reference to specific numbered "build-brief prompts" in this build plan as pointing at a doc that was never written — not this file's scope to author that content.
    • Reduced-motion handling, focus-ring visibility, and actual scroll-triggered animation behavior were not executed/verified in this pass (no browser run performed) — code presence is confirmed, runtime behavior is not.

Phase 2 — Marketing + catalog (placeholder data)

  • Landing components top-to-bottom (build-brief 2.1–2.10): Header, Hero, Philosophy, Catalog teaser, Engine, Visualisation gallery, Coverage, Editorial, Proof, Footer.
  • Full Catalog page + Dataset detail + Sourcing-map page (coverage doc 5.4). Postgres full-text search.
  • All with loading/empty/error states + mobile.
  • Accept: marketing site + catalog browse work end-to-end on placeholder catalog rows; one continuous scroll reads well; DoD met per screen.
  • Status: Partial.
    • Landing section components are real: components/sections/{hero,philosophy,gallery,editorial, pricing,technology,testimonials,footer,featured-products,collection}-section.tsx all exist.
    • app/katalog/page.tsx, app/katalog/[id]/page.tsx, app/kallor/page.tsx all exist.
    • Postgres full-text search was not found — grepped for to_tsvector/websearch_to_tsquery across the whole repo, zero matches. Catalog browsing today reads from lib/data.ts placeholder data, not a full-text query against Postgres. This Accept criterion's "catalog browse... on placeholder catalog rows" half is plausible; the Postgres full-text search half is not built.
    • Loading/empty/error states + mobile + DoD-per-screen were not independently audited screen-by-screen this pass — components/states.tsx (LoadingSkeleton, ErrorState) exists and is imported widely, which is evidence toward this but not a full verification.

Phase 3 — Auth & portal shell

  • Auth.js + Postgres adapter; sign in/up/verify/forgot. orgs/users.
  • Portal shell + Dashboard ("Mina beställningar") with empty state.
  • Accept: a user can sign up, land in an empty dashboard, sign out; routes protected.
  • Status: Not done — this is the most consequential gap in the whole repo, not a minor loose end.
    • There is no Auth.js integration. lib/auth.ts is a from-scratch, ~40-line HMAC-SHA256 session scaffold (Web Crypto, no dependency) whose own comment states: "Production auth = Auth.js + Postgres adapter + OAuth (docs/02) — wired once the database is provisioned." app/api/auth/login/route.ts is a dev-only login gated by a shared DEV_LOGIN_KEY, not sign-up/verify/forgot.
    • orgs and users tables exist in sql/001_init.sql (schema is ready), but nothing in the app reads/writes them for real session-bound identity today.
    • app/portal/page.tsx and sibling portal pages exist (shell is real), but routes are not protected by any real session check tied to that schema.
    • Concretely, this means the tenant/org isolation several 0pt.* API routes assume does not exist yet. app/api/0pt/lakehouse/mappings/route.ts, .../writes/route.ts, app/api/0pt/sources/rate-policies/route.ts, app/api/0pt/sources/[sourceId]/query/route.ts, and src/services/document-capture-service.ts all read tenant_id/project_id straight from the request body — anyone can claim any tenant. app/api/orders/route.ts's own GET handler carries the literal comment // org scoping added with auth. This is an open security gap, not a two-tier design choice — see docs/08-infrastructure.md's "Security & compliance" section for the full list. Do not build any doc or feature that assumes tenant-scoped auth already exists.

Phase 4 — Order lifecycle end-to-end (test mode)

  • Configurator (2.12) wired to POST /estimates (real estimate math in FastAPI).
  • Deposit checkout (2.13) → Stripe test Checkout → webhook → order IN_PRODUCTION.
  • Production tracker (2.14) reading GET /orders/{id}/events (SSE) — pipeline may be simulated at this phase.
  • Validation & delivery (2.15): tabs render from a sample delivery; accept → POST /payments/release (test) → DELIVERED; revise leaves escrow untouched.
  • AI-assist (POST /ai/parse) prefilling the configurator.
  • Accept: a full custom order can be configured, deposit paid (test), watched, validated, accepted, and "delivered" — state machine in docs/01 traversed; escrow split correct.
  • Status: Partial — the payment/escrow spine is genuinely real; the SSE tracker is not.
    • app/api/estimates/route.ts exists.
    • Deposit checkout is real, working code: app/api/payments/[order_id]/deposit/route.ts calls Stripe's Checkout Sessions API directly (not a stub) and app/api/webhooks/stripe/route.ts verifies the webhook signature and advances order status via setStatus.
    • app/api/payments/[order_id]/release/route.ts is also real: it enforces the escrow rule in code — balance release is rejected with 409 not_accepted unless order.status === "accepted" — matching the "escrow split correct" accept criterion's intent.
    • GET /api/orders/{id}/events (SSE) does not exist. Only GET/PATCH /api/orders/{id} were found (app/api/orders/[id]/route.ts); no /events sub-route, and no EventSource/ text/event-stream/ReadableStream usage anywhere in app/ (grep-verified). If a production tracker is live today, it's polling GET /api/orders/{id}, not the SSE stream this bullet describes — or it hasn't been built. Either way, the SSE contract as written is not implemented.
    • app/api/ai/parse/route.ts is real: a genuine (non-fabricated) keyword/regex heuristic that prefills a config object, with a documented fallback to engineFetch("/ai/parse", ...) when ENGINE_URL is set. This one holds up as described.

Phase 5 — Real data engine (Nordic slice)

  • Compliant connectors first: GLEIF + Brreg + Companies House (verify access terms; record in sources.yaml). Remove any FlareSolverr/bypass path (docs/06).
  • Pipeline stages produce real dim_company + fact_financials with bridge_provenance; QA threshold drops low-confidence rows.
  • Production tracker now reflects real job metrics.
  • Accept: a Nordic company dataset is produced for a real order, every field has provenance, quality report generated, level-0 fields shown honestly (not fabricated).
  • Status: Partial.
    • services/api/sources/sources.yaml and connectors.py are real: GLEIF, Brreg, Companies House, PRH, and CVR are all registered, each with an explicit licence: "... — VERIFY" tag rather than an assumed-clean claim — matches the "verify access terms" instruction.
    • services/api/workers/adaptive_router.py's own comment confirms: "There are NO bypass/stealth/ proxy-rotation tiers here, by design (CLAUDE.md §1)" — the FlareSolverr/bypass removal is done, at least on this repo's main (see docs/08-infrastructure.md's "Known open question" about the diverged Gitea remote, which independently added such a stack on its own main — unresolved which remote is canonical).
    • services/api/workers/pipeline.py has a real, working confidence-threshold QA drop: delivered = [r for r in deduped if r["confidence"] >= threshold], plus an avg_confidence figure in its output. This is a genuine, narrow QA mechanism inside the Python engine.
    • This is not the same thing as the "quality report" / quality-check layer once described in docs/ICEBERG_WRITE_BOUNDARY.md, docs/11-lakehouse-write-plan.md, and docs/P0_QUERY_SOURCE_FLOW.md. Those three docs used to cite src/services/quality-check-service.ts, src/services/quality-report-service.ts, src/types/quality-{check,report}.ts, and src/data-platform/services/catalog-service.ts as already-built and tested. None of those files has ever existed in this repo's git history, on any branch — and all three docs have since been corrected to say so explicitly (verified this pass), rather than repeating the original claim. So: the narrow Python-side confidence filter is real; the broader TS/lakehouse "quality report" this Accept criterion likely also intends is not built (see docs/P0_DATASET_QUALITY_FLOW.md for the honest target spec). Whether pipeline.py's avg_confidence output satisfies "quality report generated" is a judgment call for whoever runs this phase — flagging rather than deciding it here.
    • Whether an actual Nordic dataset has been produced end-to-end for a real order was not verified this pass (would require running the stack).

Phase 6 — Delivery surfaces & integrations

  • Exports: CSV / Parquet / JSON via R2 presigned URLs.
  • Embedded NocoDB ("Min data") and Metabase dashboards via signed URLs in mono chrome.
  • Integrations: FiveCRM push, webhooks, Google Sheets.
  • File uploads (Uppy → R2) for matching/enrichment.
  • Accept: a delivered dataset is downloadable, viewable in embedded NocoDB + Metabase (no separate login), and pushable to FiveCRM; uploads work.
  • Status: Not done — this phase is UI-shell-only today.
    • CSV/Parquet/JSON exports via R2 presigned URLs: not found. app/api/uploads/route.ts (the closest R2-touching code) is a 501 stub with a hardcoded put_url: null — no presign logic exists yet, for uploads or exports.
    • components/embed.tsx (EmbeddedView) is real — a sandboxed iframe shell — but the server-side signed-URL generator for Metabase/NocoDB (METABASE_SECRET_KEY/NOCODB_API_TOKEN) that would feed it a real src was not found anywhere in this pass.
    • "FiveCRM" appears only as a destination label string in lib/data.ts, lib/services.ts, app/portal/integrationer/page.tsx (explicitly described in that file's own comment as "presentationella toggles" — i.e. presentational, not wired), and as one of several regex-matched destination names in app/api/ai/parse/route.ts's heuristic. No actual FiveCRM API push exists.
    • uppy is not a dependency in package.json (checked directly) — the Uppy → R2 upload flow this bullet describes has no corresponding package installed, let alone implemented.

Phase 7 — Subscriptions / feeds

  • subscriptions; recurring jobs (weekly/daily) delivering deltas; webhooks on change.
  • Freshness monitoring; Resend emails (confirmation, "din data är klar", feedback).
  • Accept: an order converts to a feed; a scheduled run produces a delta + fires a webhook + email.
  • Status: Partial — data layer real, scheduling and email are not.
    • lib/subscriptions-store.ts is real CRUD against the subscriptions table (with an in-memory fallback when no DB is configured) — createSubscription/listSubscriptions genuinely insert/read.
    • No recurring-job scheduler (cron, queue consumer producing deltas, etc.) was found on the TS side of this repo. services/api/workers/queue.py exists on the Python side but this pass did not trace whether it implements actual delta-producing scheduled runs — flagged as unverified rather than assumed either way.
    • Resend is not wired at all — grepped for resend/Resend across the whole repo, zero matches outside RESEND_API_KEY being listed as an env var. No confirmation/"din data är klar"/feedback email has been implemented.

Phase 8 — Admin, hardening, compliance

  • Admin: order queue + scope/quote + escrow release; source registry & freshness monitoring; worker/job monitoring; customer + catalog management.
  • Customer DB-access grant flow (encrypted, read-only, time-boxed) + revoke.
  • Security pass: RLS, presigned-only files, signed embeds, secret rotation, audit_log everywhere; DPA published; legal pages.
  • Finalize all loading/empty/error states + mobile across the app.
  • Accept: admin can run the business; DB-grant flow works and self-revokes; security checklist passes; DoD met app-wide.
  • Status: Partial — UI shell real, the security substance is not.
    • Admin pages exist: app/admin/{page,jobs/page,sources/page,customers/page,verktyg/page}.tsx. This pass did not verify whether they read real backend data or placeholders.
    • sql/001_init.sql defines audit_log, db_grants, and payments tables — schema is ready.
    • The DB-access grant flow is a stub, not a working feature. app/api/connections/db-grant/route.ts returns 501 without GRANT_ENC_KEY, and with it set returns a hardcoded 201 { grant_id, status: "pending" } — no AES-GCM encryption, no read-only validation, and no self-revoke logic exist in code today, despite the route's own comment describing that target behavior. See docs/08-infrastructure.md's "Security & compliance" section.
    • RLS enforcement, presigned-only files (blocked on the Phase 6 R2 gap above), signed embeds (blocked on the Phase 6 NocoDB/Metabase gap above), and a published DPA were not found as implemented in this pass. Treat the "security checklist passes" accept criterion as not met.

Phase 9 — Launch

  • Domain 0pt.app (Vercel) + subdomains (api/data/dash via Caddy). Sentry + uptime live.
  • Soft launch: Protosell internally + a handful of existing dealer relationships.
  • Accept: a real external customer completes an order start→delivery in production; feedback loop running.
  • Status: Domain is live; soft-launch/customer criterion not verified.
    • Verified this pass (network check performed): https://0pt.app returns HTTP 200 and serves this repo's own Next.js app (title "0pt — your data", SIAX branding); GET /api/health returns {"ok":true,"service":"0pt-web","db":"not_configured","queue":"not_configured",...}, an exact shape match for app/api/health/route.ts. README's "Live: https://0pt.app" is confirmed, and the response confirms the live deployment has no DATABASE_URL/REDIS_URL set — see docs/08-infrastructure.md's "Hosting topology" section. This does not confirm which git remote/commit is deployed (see the diverged-remotes note elsewhere in this doc), or that api/data/dash subdomains or Caddy are live — only the root Vercel deployment was checked.
    • Sentry is not wired — no sentry package in package.json, no Sentry config file anywhere in the repo (grep-confirmed). @vercel/analytics is genuinely wired (app/layout.tsx renders <Analytics />), which is a real but much smaller piece of the "Sentry + uptime live" bullet.
    • No evidence either way on the soft-launch/external-customer criterion — out of this doc's ability to verify from code alone.

Working conventions (for Claude Code)

  • Tech docs + identifiers English; UI copy Swedish.
  • Conventional Commits; one concern per PR; keep diffs reviewable.
  • No secrets in code; update .env.example when adding env. (.env.example itself currently lives on feature/0pt-env-example / PR #3 — merge that first, or this instruction has nothing to update yet.)
  • Add/extend tests for engine logic (estimate math, fusion/confidence, dedup) and API contract.
  • When a source's access/licence is unclear, verify and record in sources.yaml before relying; if disallowed for bulk, route to licensed/partner or mark level 0 — never bypass, never fabricate.
  • Touch the design tokens/motion only via docs/05; keep red ≤ once per screen; no photography.
  • If anything here conflicts with existing code, flag it in the PR description and propose the reconciliation rather than guessing.
  • Two git remotes claim to be this repo (github.com/sax3l/0pt and git.siax.io/sax3l/0pt), and they have independently diverged (see CLAUDE.md, README.md). Don't assume the checkout you're in is the only copy, and don't silently pick one as canonical — that's Simon's decision, flag it if it becomes relevant to the phase you're working on.