- ADR-0003: modular monolith for Phase 0 with documented extraction triggers - ADR-0004: Auth.js + OIDC-shaped boundary; dedicated provider when mobile ships - ADR-0005: protobuf for events, OpenAPI for HTTP, schema-registry CI gate - New architecture docs: data-model, metrics (magic proxies), privacy (Phase-0 feature) - Prime directives updated: privacy-as-feature, modular-by-package-deployable-by-stage - Roadmap revised: Apple OAuth deferred to M1; web push in M1; k3s intermediate; tip-kind-aware UI - PLAN updated: Phase-0 deletion endpoint, metrics baseline, compose profiles, import-boundary lint - License decision in README (ARR with OSS plan in Phase 5)
5.7 KiB
Implementation plan
Step-by-step build order for Phase 0 (walking skeleton) and the seams that make Phases 1–5 cheap.
The principle: build the contracts first, stub the internals. Every module exposes its contract and a /health story before any module is "finished". End-to-end walking skeleton in the first week.
Packaging reminder (ADR-0003): Phase 0 is a modular monolith — one Node process bundles services/* behind their HTTP contracts, plus ml/serving as a separate Python process. Contracts are identical whether the call is in-process or over the wire.
Stage 0 — Foundations (days 1–3)
- Monorepo tooling. pnpm workspaces for TS; uv for Python; turbo for build graph; pre-commit (eslint, prettier, ruff, mypy, typecheck).
- Docker Compose dev env with profiles:
core— Node monolith +ml/servingstub + Postgres.full— adds NATS, MinIO, MailHog. Needed from Stage 4 onward.
- CI skeleton (Gitea Actions): lint → typecheck → unit → build → publish images. Schema-registry check for protobuf events (added in Phase 1, but pipeline stub now).
- Secrets convention.
.env.exampleper module; prod injected by orchestrator. - Shared types. OpenAPI for HTTP, protobuf for events (ADR-0005). Generate TS; Python pydantic models hand-written initially (few consumers).
- Import-boundary lint.
eslint-plugin-boundaries(or equivalent) preventsservices/integrationsfrom importingservices/recommenderinternals. Contracts-only.
Exit: docker compose --profile core up brings a green dashboard of /health endpoints.
Stage 1 — Identity & session (days 4–7)
services/authmodule: Auth.js embedded in the Node monolith, Google provider only (Apple deferred). OIDC-shaped surface (ADR-0004):/me,/logout, JWKS, stub/.well-known/openid-configuration.services/profilemodule:Userrow created on first sign-in; consent record captured with ToS/PP version hash.apps/websign-in page. Gateway (also in-process) verifies JWT.- Deletion endpoint (yes, already):
DELETE /me— revokes sessions, flipsdeleted_at, emitsuser.deletion_requested.
Exit: a user can sign in, see their profile, and delete their account; deletion is observable end-to-end even though there's no data to erase yet.
Stage 2 — Integrations framework (days 8–12)
services/integrationsmodule with a Connector interface:beginOAuth(user) → {redirectUrl, state}finishOAuth(code, state) → StoredCredentialfetchSignals(user, since?) → AsyncIterable<NormalizedEvent>act?(user, action) → voidrevoke(user) → void— first-class; no revocation means no disconnect.
- Token vault: libsodium sealed box, key from env/KMS. One row per
(user, provider)with provider-specificmeta(e.g. Todoistsync_token). - Todoist connector: OAuth2, Sync API incremental reads via
sync_token,actto complete a task,revokecalls Todoist's token-revocation endpoint. - Web
/connect: list of connectors, per-connector consent screen (scopes + retention), connect/disconnect.
Exit: a user can connect and disconnect Todoist; disconnect revokes at Todoist and wipes local credentials.
Stage 3 — Recommender contract (days 13–16)
services/recommendermodule exposesPOST /recommendandPOST /feedback.- Policy registry keyed by name. Candidate sources registered independently; v0 source =
integrations.todoist.tasks. RandomPolicyv0 — draws uniformly.- Tip shape provider-agnostic:
{id, kind: "todo"|"advice", title, body, source, deep_link, meta}. TipInstancepersisted withcontext_snapshot— the features-seen-at-decision-time blob that makes offline replay possible later.apps/webtip page:kind=todo→ tap = done (callsintegrations.todoist.act(complete)).kind=advice→ tap = acknowledge; long-press = save.- Snooze / dismiss via long-press menu regardless of kind.
- Every reaction emits a feedback event even though it's in-process today.
Exit: three-page prototype works end-to-end.
Stage 4 — Hardening (days 17–20)
- Observability: pino + structlog, Sentry per module, W3C traceparent across the monolith boundary and into
ml/serving. - Rate limits, retries with jitter, and circuit breakers on outbound (Todoist, Google).
- Integration tests: Playwright for the web flow (sign-in → connect → tip → delete). Contract tests between modules so the extractions later are safe.
- Metrics baseline wired (
docs/architecture/metrics.md): activation, first-tip reaction, dwell, snooze:dismiss ratio, D1 retention. - Deploy to a single VM via docker-compose + Caddy; Caddy auto-TLS; healthchecks wired to Caddy.
Exit: Phase 0 milestone closed; real users can be onboarded.
Seams prepared for later phases (designed now, implemented later)
- Event bus abstraction.
emit(event)/subscribe(topic, handler)today is in-process; the production implementation in Phase 1 is NATS JetStream. Callsites never change. - Feature assembler. Recommender accepts a
contextblob from aFeatureAssembler; in Phase 0 it returns a hard-coded minimum; in Phase 1 it calls the feature store. - Shadow-policy hook. The recommender already supports running N policies in shadow per request; v0 runs zero shadows but the hook exists.
- Extraction-ready modules. Every
services/*/has aserve.tsthat can be mounted in the monolith or booted standalone. Dockerfile targets both.
Staffing assumption
Three parallel streams: platform (infra, CI, shared-types), backend (auth, profile, integrations, recommender), web (sign-in, connect, tip, PWA). ml joins in Phase 1. Each Gitea issue carries its stream label and milestone.