Compare commits

...

1 Commits

Author SHA1 Message Date
CIAgent 36cd9fa71f docs(P00): complete b2b-employer-network phase 0 — v0.9.0
CI / node (push) Successful in 43s
CI / python (push) Successful in 2m23s
Release / release (push) Successful in 2m54s
Specify (D-085 scope lock: REQ-F-019 activated as REQ-10-001..005),
clarify (D-086..D-097), research (R-55..R-66 — session-auth patterns,
scrypt, the claim-evidence seam; in-tree integration probes), plan
(6 vertical-slice phases, waves binding, must-haves falsifiable),
grill (G-311..G-320 binding decisions applied), MVP/UX gate PASS.
Tags on the v0.9.x line; branch
milestone/v0.10-b2b-employer-network.

---ci---
phase: 0
milestone: v0.10
status: complete
---/ci---
2026-09-30 15:19:25 +00:00
10 changed files with 785 additions and 385 deletions
+35
View File
@@ -59,6 +59,41 @@ Nextcraft is a TypeScript monorepo (pnpm workspaces + turborepo) with a Next.js
**Money (D-081):** integer cents end-to-end; pydantic `int ge=0`; TS `number` documented as cents; currency `usd` fixed.
### v0.10 B2B Employer Network Architecture (Phase 0 RESEARCH — B2B Employer Network, D-086..D-097 binding)
**Module:** `ai_service/employers/` (8th domain module, D-031 pattern — extend ai-service, no new apps). Boundary rule (family discipline): `employers/` never imports `agents/` or `api/`; it imports `config` + its own files only. Files: `base.py` (pydantic contracts: EmployerAccount, EmployerClaim, session/token response shapes — dual-schema-pinned against packages/types/employer.ts, D-093; PostingSummary reused from marketplace.ts, never duplicated), `store.py` (8th D-027 SQLite store, 4 tables, D-095), `passwords.py` (stdlib scrypt KDF core — the billing/webhooks.py crypto-core precedent; D-086 params N=2^14/r=8/p=1/dklen=32 as module constants pinned by test, self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` rows, `hmac.compare_digest` verify; per-password `secrets.token_bytes(16)` salt; zero new deps), `claim_evidence.py` (stdlib registrable-domain normalization — urlsplit hostname + suffix match; the advisory match signal, D-089). HTTP surface in `api/employers.py` (`/v1/employers/*` — session-gated) + `api/employers_admin.py` (`/v1/employers/admin/*` — require_admin imported from marketplace_admin, the billing_admin.py:27 precedent, D-094). No service.py — routes compose the store via `deps.py` app.state DI (`get_employer_store`, the "routes never import stores directly" territory rule).
**Store (D-087/D-089/D-095, 4 tables on the shared `Settings().db_path`):** `employer_accounts` (id PK, email UNIQUE indexed, password_hash self-describing scrypt string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), never the token; account_id indexed, created_at, expires_at = login + SESSION_TTL_DAYS), `employer_claims` (id, account_id, employer_id indexed, status pending|approved|rejected, evidence JSON `{account_email_domain, entity_website_domain, match}`, created_at, decided_by, decided_at), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape). Mechanics mirror billing/store.py exactly (WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas, `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol + SQLiteEmployerStore pair, surfaced IntegrityError, check-first unique patterns — the insert_application precedent; G-304-style single-statement writes where concurrency matters). The `employer_` prefix is mandatory — bare `employer` is marketplace's EmployerRow table. `SQLModel.metadata.create_all` is idempotent: zero migration, the 4 tables join existing DBs at next boot (the proven billing-store compat).
**Session flow (D-087/D-090):** register (email+password, UNVERIFIED per D-097 — no learner gates, no email verification; scrypt hash via passwords.py) → login mints `secrets.token_urlsafe(32)`; the row stores `sha256(token)` + expires_at = now + 7-day module constant (lazy TTL at read — G-306, no timer); the client persists the opaque token in sessionStorage `nc-employer-session` (the G-308 adminToken wrapper pattern) and attaches `Authorization: Bearer <token>` on every fetch (CSRF-immune by construction — no ambient credential; D-039 static export has no Next middleware). The `require_employer_session` dependency (the require_admin extract→verify→return shape): **401** on missing/expired/unknown token (credential failure), **403** reserved for cross-entity authorization; returns session + account + claimed employer_id. logout DELETEs the row + audit event (revoke = lookup miss → 401); multiple concurrent sessions per account allowed. Token namespaces cannot collide: admin routes compare against the env token, employer routes hash-lookup.
**Auth throttling (D-088, G-5 in-memory pattern):** (a) per-EMAIL failed-login window — Settings knob, default 5 failures / rolling 15 min → 429, successful login clears the email's window; (b) global per-min cap on login+register attempts (the `_check_global_create_rate` deque) → 429. Keyed by SUBMITTED EMAIL, never client IP (behind HAProxy the proxy owns request.client.host). Both copy the proven deque/monotonic-window mechanics (identity _SubmitRateLimiter + sandboxes global cap); test fixtures reset the module state.
**Claim flow (D-089):** authenticated `POST /v1/employers/claims {employer_id}` → claim row status=pending with computed evidence (stdlib domain normalization; every seeded employer website is an example.com placeholder → match=false corpus-wide — the signal is advisory, A-304) → operator reviews in the admin queue → approve sets employer_accounts.employer_id (UNIQUE — one account : one entity) + decided_by/decided_at + audit event; reject frees the entity for a new claim. One PENDING claim per entity + one APPROVED owner per entity enforced check-first in the store. Auto-approval deferred: without email verification the match is forgeable — the operator decision activates.
**Entitlement composition (D-091) — one shared helper, two principal gates:**
```
admin arm: require_admin ──▶ consume_posting_entitlement(store, employer_id, admin-CTA) ──▶ insert
employer arm: require_employer_session ──▶ claim check (403) ──▶ same helper(session employer, employer-CTA) ──▶ insert
helper: store-missing → 503 fail-closed
NoActivePackage → 402 {reason: no_active_package, posts_used: 0, posts_limit: 0, upgrade_cta}
PackageExhausted → 402 {reason: posting_limit_reached, posts_used: 0, posts_limit: 0, upgrade_cta}
(posts_used/posts_limit hardcoded 0 in BOTH arms — the v0.9 wire contract, byte-identical;
UpgradeRequiredError consumes reason + upgrade_cta unchanged; gate runs BEFORE insert)
```
`consume_posting` (G-304 single conditional UPDATE) is untouched; the helper lives in api/ beside both callers (no billing/→api/ boundary violation).
**Employer checkout (D-091, transposes billing_admin.create_checkout):** `POST /v1/employers/billing/checkout {item_id}` — session-gated; unclaimed → 403 + claim-CTA; buyer = the session's claimed entity (supersedes D-076 operator mediation); catalog snapshot + unique-pending placeholder + CheckoutSessionSpec metadata employer_id=claimed entity, operator_ref→account provenance; success/cancel URLs from public_base_url (G-310); employer mock-complete arm mounted only under provider=mock, employer-session-gated, completing one's OWN session (403 cross-session), minting the signed event through the REAL webhook path (D-082 transposed). The v0.9 provider protocol/webhooks/store grants serve employer checkouts verbatim — only principal derivation changes.
**Web (D-092/D-093, engine-client employer section — no new client module):** `engine-client.ts` gains register/login/logout/me, claim, own-postings CRUD/publish/close, own-checkout, own-entitlement + a `SessionRequiredError` (401 discrimination — parseError today has no 401 arm; honest logged-out state, never a retry loop) + the `nc-employer-session` sessionStorage wrapper (the adminToken pattern; logout clears storage + calls revoke; AbortController everywhere). Conversion scope: postings page (own-postings lifecycle), overview page (real active-postings count + per-posting applicant counts from the applications table; talent-match/placement metrics stay mock-labeled), employer profile (claimed entity via the existing public GET), new /employer/login + /employer/register (greenfield — no login page exists in any route group); **talent search + candidate profile stay the v0.1 mock** (no candidate backend — D-092). Wire types in `packages/types/employer.ts` dual-schema-pinned in pins.test.ts (the billing.ts pin block template, D-093). Pricing CTAs convert from contact-path to employer-login path (supersedes D-079).
**Admin network overview (D-094):** the moderation page (app/(admin)/admin/moderation/page.tsx — where the v0.9 EmployerEntitlementPanel landed) gains sibling sections below it: employer accounts list (email, claimed entity, entitlement state), claim queue (evidence + domain-match signal, approve/reject), audit — fed by operator routes in api/employers_admin.py (require_admin + audit events): GET /v1/employers/admin/accounts, GET …/claims, POST …/claims/{id}/approve|reject, GET …/audit; web transport via the existing adminToken() G-308 wrapper + honest 403 locked states (the entitlement panel's locked-state pattern).
**Settings/wiring (D-095/R-60):** throttle knobs as Settings ints (the identity_submits_per_min shape) + .env.example entries beside AI_ADMIN_TOKEN; session TTL is a module constant (no env). main.py: employer_store lifespan wiring (the billing_store getattr-guard template, ~line 182-190) + close() in the finally block; employers + employers_admin routers mount beside the billing block (inline-import pattern); CORS `allow_headers` gains "Authorization" (main.py:341) with a tests/api/test_cors.py preflight pin — the E87 lesson applied before it ships this time. scrypt probe-verified in the shipped venv (3.11.2): ~50 ms/hash at D-086 params, default maxmem admits the 16 MiB working set.
### v0.8 Distribution & Release Architecture (Phase 0 RESEARCH — Ship Anywhere, D-063..D-072 binding)
**Runner fleet topology (CT 113, 10.0.0.113):** three org-level gitea-runner containers (official image `docker.io/gitea/runner:3` — the Docker Hub `gitea/act_runner` repo is deprecated, renamed `gitea/runner`) in **sibling-container mode**: each mounts the host `/var/run/docker.sock`, has its OWN `/data` volume holding the `.runner` registration file (identity + credentials; shared state = re-registration), registers once against `https://git.coreci.dev` (Gitea 1.26.1, probe-verified) with the founder-provided org-level registration token (chat-only, reset after fleet green). Hybrid labels via `GITEA_RUNNER_LABELS`: `node` → `docker://node:24-bookworm`, `python` → `docker://python:3.11-bookworm`, `release` → `docker://node:24-bookworm`, `ubuntu-latest` → `docker://docker.gitea.com/runner-images:ubuntu-latest` (label format `name[:schema[:args]]`; a job's `runs-on` matches label names, the `docker://` image selects the job container). Per-runner `runner.capacity: 1` (default; also caps per-job docker subnet consumption) → fleet concurrency 3. Cache: each runner's built-in cache server must be pinned to a job-reachable `cache.host` + fixed published port (dockerized runners otherwise create per-job networks the cache address is unreachable in — `actions/cache` fails ETIMEDOUT); 32 GB disk guarded by `cache.repo_size_limit`, `health_check.min_free_disk_space_mb`, and a host-side `docker system prune` cron. Workspace-path alignment inside/outside the runner container is NOT needed for our jobs (no `container.bind_workdir`; `docker build` TARS the context over the API) — documented as the known sibling-mode gotcha if a future workflow bind-mounts.
+9
View File
@@ -0,0 +1,9 @@
{
"phase": 0,
"stage": "ship_pending",
"milestone": "v0.10",
"milestone_name": "b2b-employer-network",
"phase_role": "pre_execution",
"attempts": 0,
"updated_at": "2026-09-30T15:52:00Z"
}
+68
View File
@@ -263,3 +263,71 @@ None. All four seams remain founder-locked via D-016 / REQ-5-001..007; every fin
- a-202: `GITEA_TOKEN` naming collision (automatic job token vs repo secret of the same name) — Gitea precedent lowest-level-wins; keep the repo secret name `GITEA_TOKEN` per R-13 but document the collision in the workflow comments.
- a-203: cache.host on CT 113 IP — if the LXC ever changes IP, cache breaks; fleet-as-code (G-207) documents the IP as a bring-up variable.
- a-204: image `latest` tag vs immutable release tags — `latest` is convenience-only; runbook always pins the release tag.
---
## v0.10 Grill (Phase 0 GRILL, 2026-09-30)
**Stage:** GRILL, Phase 0 pre-execution · **Verdict:** SHIP-WITH-BINDINGS · **Confidence:** 0.82 · **Date:** 2026-09-30
**Verdict summary.** The v0.10 B2B Employer Network plan (6 phases, 15 waves, 30 tasks) is feasible and correctly sequenced — every load-bearing tree citation re-verified against the working code and held (require_admin shape, the 402 gate order + hardcoded zeros, billing_admin checkout/mock-complete mechanics, the four main.py wiring sites, the web census, both throttle precedents, the tests/billing layout, scrypt probe). The researcher's six open risks are each carried by a plan task. But red-teaming found 10 binding defects: a REQUIREMENTS-vs-PLAN gate-order contradiction (REQ-10-004 literally says sessions compose AFTER the entitlement check — backwards, the G-307 class); "web render tests" specified against a harness that does not exist in-tree (D-059 declined it — the tests as written are undeliverable without smuggling in jsdom, violating R-66); a suite-breaking throttle trap (the global 30/min deque is module state shared across every tests/employers/ file — the whole fast suite runs inside one 60s window and 429s itself around test ~16); a credential-stuffing hole hiding in an ambiguous throttle-append rule (if failed passwords don't consume global budget, D-088's stated rationale is void); an EmailStr trap (email-validator is NOT installed — the natural pydantic choice is a boot-time ImportError and the first new dependency); a TOCTOU race on one-pending-claim-per-entity with no UNIQUE backing it (insert_application's check-first is safe only because a real constraint backs it); employer postings that would inherit source="admin" provenance if the admin route is copied; a re-tag fork between admin and employer edit paths (D-052 drift); a dual-schema pin pointed at contracts no task creates (the own-postings response has no named pydantic model); and a UX criterion inviting the executor to "fix" the 402 wire zeros (risk 7 stated as acceptance criteria). All ten amend existing tasks — nothing re-architects, no scope cuts required.
## Per-Axis Findings
| # | Axis | Verdict | Reason |
|---|------|---------|--------|
| 1 | Feasibility | CONCERN → fixed | All citations verified in-tree; but 4 tasks assume things the tree doesn't have: a render harness (G-312), EmailStr's dependency (G-315), a backing constraint for the claim 409 (G-316), a named own-postings pydantic contract for the MH-3a pin (G-319) |
| 2 | Scope | PASS | Big but REQ-mandated, heavy precedent reuse (the v0.6 46-task cadence); talent pages honestly left mock; no half-built surface found; no CUTs |
| 3 | Security | CONCERN → fixed | New trust-boundary code mostly well-specced (hashed tokens, lazy TTL, email-keyed throttles, 422 scrub, own-session mock-complete); defects: throttle-append ambiguity voids the stuffing bound (G-314), throttle-vs-KDF order unspecified (G-320a), claim insert race (G-316) |
| 4 | Honesty | CONCERN → fixed | "Render tests" would present source pins as render tests or force a harness dep (G-312); employer postings inheriting source="admin" is provenance dishonesty (G-317); UX criterion 3 contradicts the byte-stable 402 zeros (G-320b) |
| 5 | Drift | CONCERN → fixed | D-091 pins strong (payload-parity test + unchanged v0.9 suite); but the own-postings dual-schema pin has no pydantic contract to pin against (G-319), and employer edit would fork the admin re-tag semantics (G-318) |
| 6 | Sequencing | PASS | Wave order verified dependency-sound (P1 module → P2 claims → P3 surface → P4 writes → P5 money); pins land against base.py contracts, not pages — no hidden dependency |
| 7 | Researcher open risks | PASS | All six (scrypt burst, CORS preflight, 422 echo, sessionStorage, mock-complete own-session, example.com evidence) carried by named tasks with pins |
| 8 | Test coverage | CONCERN → fixed | Several must-haves unverifiable AS WORDED (render tests, G-312); the employers suite self-DOSes without a conftest-wide reset (G-313); stuffing untested (G-314) |
| 9 | One-entity/one-account races | CONCERN → fixed | One-APPROVED-owner is structurally sound (employer_accounts.employer_id UNIQUE → IntegrityError, pinned); one-PENDING-per-entity is check-first with NO backing constraint — TOCTOU (G-316) |
| 10 | Token entropy + storage | PASS | token_urlsafe(32) = 256-bit CSPRNG, sha256-at-rest, lazy 7-day TTL, revoke-on-logout, concurrent sessions — matches D-087 exactly; no finding |
## Binding Decisions (G-311..G-320 — applied to PLAN/REQUIREMENTS before execute)
**G-311 — REQ-10-004's gate-order wording is backwards; the session gate composes BEFORE the entitlement check.** REQ-10-004 says "employer sessions compose AFTER the existing entitlement enforcement" — literally: an unauthenticated caller hits the 402 gate before the 401, leaking entitlement state to anonymous callers and breaking the web client's SessionRequiredError-vs-UpgradeRequiredError discrimination (a 402 on an expired session renders an upgrade CTA to a logged-out visitor — a lie). The plan (Task 4-1-02, R-58) has the correct order; the requirement text contradicts it (the G-307 class: requirement prose naming a composition that must not exist). Decision: amend REQ-10-004 to "the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the R-58 admin-gate-first order preserved)". Applies to: REQUIREMENTS REQ-10-004; PLAN Task 4-1-02 (amendment note). Test: unauthenticated create → 401 (never 402); unclaimed-session create → 403 (never 402). Confidence 0.85.
**G-312 — no render harness exists in-tree: page/component tests are fetch-stub + source-pin, never "render tests".** Tasks 3-3-01/3-3-02 say "fetch-stub render tests" / "web render tests" — D-059 explicitly declined jsdom/happy-dom (v0.7, founder-scoped), the billing-entitlement.test.ts template's own header says "no render harness in-tree", and adding one is a new dependency violating R-66's zero-dep claim. As written, the executor either smuggles in jsdom or ships tests whose names lie about what they verify (the A-304 class). Decision: every "render test" is reworded to the D-059/E87 discipline — fetch-stub tests over engine-client employer functions (401 → SessionRequiredError, token header attached, logout revoke+clear) + source pins for component honest states (logged-out/claim-pending/loading/error branches present in source, the pins.test.ts discipline); jsdom/happy-dom are BANNED this milestone. Applies to: PLAN Tasks 3-3-01, 3-3-02 (amendment notes; 4-2-01/5-2-01/5-2-02 already say "web fetch-stub" — their loose "render" phrasing is covered by this G); MH-3b/3c/3e and MH-5c/5e read under this discipline. Confidence 0.85.
**G-313 — both throttle limiters reset via a SUITE-WIDE autouse fixture in tests/employers/conftest.py.** The global cap (default 30/min) is module-level process state (R-63); the entire tests/employers/ suite — register+login flows across test_auth, test_claims, test_claims_admin, test_postings_read, test_postings_write, test_checkout, test_checkout_e2e — runs in ONE process in well under 60 seconds, i.e. inside ONE window: ~90+ auth attempts against a 30-cap → guaranteed mid-suite 429s cascading through every downstream test. The plan places reset fixtures only in test_throttle.py's test list. Decision: tests/employers/conftest.py (a NEW file — the sanctioned deviation from billing's no-conftest layout, R-64) carries an autouse fixture clearing BOTH limiter module states before/after EVERY employers test; the per-file fixtures in test_throttle.py stay for its own window-math. Applies to: PLAN Task 1-2-02 (amendment note), D-096 operationalization. Test: the suite itself (it cannot pass without this — the trap is self-revealing but only after a day of flaky archaeology). Confidence 0.85.
**G-314 — the global cap counts every well-formed attempt, including failed passwords — or D-088's rationale is void.** The plan's "append only after ALL checks pass (a rejected attempt never consumes budget)" is copied from the sandbox create-cap, where "rejected" means server-rejected-by-validation. Read against auth, "all checks pass" plausibly includes password verification — under that reading, failed logins consume nothing and credential stuffing with rotating emails (the exact attack D-088 names: "bounding cross-email credential-stuffing volume") is UNBOUNDED, and the planned test "rejected attempts don't consume the global budget" would PIN the hole. Decision: the global deque appends for every well-formed login+register request that passes body validation, BEFORE the KDF runs — successes and failed passwords both consume budget; only 422-invalid bodies and the cap's own 429s are exempt; the per-email window keeps failures-only semantics. The test is reworded: "422-invalid attempts consume nothing; FAILED-PASSWORD attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s (stuffing test, knob set low)". Applies to: PLAN Task 1-2-02 (amendment note), MH-1d. Confidence 0.75.
**G-315 — email fields are plain str with stdlib validation; pydantic EmailStr is BANNED.** email-validator is NOT in the venv (pyproject carries only pydantic/pydantic-settings; no in-tree email-validation precedent exists — identity keys on learner_id). Using pydantic's EmailStr in RegisterEmployerRequest/LoginEmployerRequest — the natural choice for "email + password, honest validation" — raises at model-build/validation time ("email-validator is not installed"), and fixing it means the FIRST new dependency, breaking R-66/D-086's zero-dep claim and the mypy zero-override regime. Decision: email is `str` with stdlib-only validation (non-empty, exactly one "@", non-empty domain, lowercased + stripped on normalize — mirrored in the store's _validate hook); the evidence domain extraction (R-65) already handles the parse. Applies to: PLAN Task 1-1-01 (amendment note). Test: register accepts "User@Example.COM" (normalized), rejects "not-an-email" with an honest 422 that never echoes the password (rides the G-312/1-2-03 pin). Confidence 0.85.
**G-316 — one-pending-claim-per-entity gets a structural backstop, not just check-first.** insert_application's check-first insert is race-safe ONLY because UniqueConstraint(posting_id, learner_id) backs it; "one PENDING claim per entity" is a CONDITIONAL uniqueness (rejected claims on the same employer_id are legal — "rejection frees the entity"), so no table-level UNIQUE exists and two concurrent claim POSTs both pass the check and both insert. Two threads reach the store (FastAPI sync endpoints run in a threadpool; the store uses check_same_thread=False per-call Sessions) — the 409 promise is TOCTOU. The one-APPROVED-owner arm IS structurally sound (employer_accounts.employer_id UNIQUE → IntegrityError, already pinned) — this G covers only the pending arm. Decision: `employer_claims.__table_args__` gains `Index("uq_pending_claim_per_entity", "employer_id", unique=True, sqlite_where=text("status = 'pending'"))` — SQLite supports partial indexes and SQLAlchemy emits them via create_all (the D-095 one-time path); insert_claim keeps its check-first 409 fast-path and SURFACES the IntegrityError as the same honest 409 under race. Applies to: PLAN Tasks 1-1-02 (schema), 2-1-02 (amendment note), MH-2c. Test: pin the partial index exists in sqlite_master; second concurrent-pending insert → 409 (store test surfaces IntegrityError, never a duplicate pending row). Confidence 0.75.
**G-317 — employer-created postings stamp their own provenance, never source="admin".** Task 4-1-02 mirrors the admin create route, whose Posting construction hardcodes `source="admin"`, `id=f"adm-…"`, `external_id=f"adm-…"` (marketplace_admin.py:221-243). Copied wholesale, an employer-authored posting presents as operator-authored — corpus provenance lies (A-304: the v0.6 ingest contract means source distinguishes seeded vs admin-authored; a third principal class silently inheriting the admin label makes "who wrote this" unanswerable and muddies the audit story the milestone sells). Decision: employer create stamps a distinct source (e.g., `source="employer"`) with its own id/external_id schemes (`emp-…`), keeping the (source, external_id) upsert identity unambiguous across all three authorship classes; the admin routes are untouched. Applies to: PLAN Task 4-1-02 (amendment note). Test: employer-created posting persists with the employer source stamp (pinned); admin routes still stamp "admin". Confidence 0.80.
**G-318 — employer edit composes the SAME re-tag path as the admin edit; no tag-semantics fork.** The v0.9 admin edit (PUT /v1/marketplace/admin/postings/{id}) calls update_posting_fields then re-tags via tag_posting — D-052: tags are DERIVED data, overwritten on edit. Task 4-1-02's employer PATCH specifies the own-drafts guard but is silent on re-tagging: an executor shipping edit-without-re-tag leaves employer-edited postings with stale tags — derived data drifting from content, a D-052 violation and a search-facet integrity hole (the exact drift class D-091 exists to prevent, on the tagging axis). Decision: the employer edit route composes the identical update_posting_fields + tag_posting path (tag failure honest — edit stands, tags stay, warning logs — the admin contract), with the own-drafts-only guard evaluated BEFORE the mutation (published/closed → 409 without side effects); PATCH-vs-PUT method naming is free choice. Applies to: PLAN Task 4-1-02 (amendment note). Test: employer edit on a mock provider re-tags (tags updated or TaggingUnavailable-swallowed-pinned); edit published → 409 AND no field/tag mutation occurred. Confidence 0.70.
**G-319 — the own-postings response is a NAMED pydantic contract; applicant data is COUNTS ONLY.** MH-3a/G-108 pins assert employer.ts against "ai_service/employers/base.py's pydantic contracts — both schemas", but no task's contract list includes the own-postings response: Task 1-1-01 names Account/Claim/Register/Login/Token/Me; Task 3-1-01 describes the route's payload in prose. The pin as specified either pins against nothing (a weak dict-shape grep) or blocks on a contract nobody was told to write. The route also needs a data source the plan doesn't name: applicant_count per posting, while the marketplace store's only read is list_applications(posting_id) — a per-posting N+1 across the employer's postings. Decision: (a) Task 3-1-01 adds named contracts to employers/base.py — `OwnPostingSummary` (PostingSummary reused from marketplace, PLUS applicant_count) and the facets/list response — so the MH-3a pin is schema↔schema; (b) the count rides a new in-pattern marketplace store read (e.g., `count_applications_by_posting(posting_ids) -> dict[str, int]`), never an N+1; (c) explicitly pinned: employer-facing responses expose applicant COUNTS ONLY — learner identifiers NEVER appear in any /v1/employers/* response (PII boundary, security-auditor P3/P5 review scope). Applies to: PLAN Tasks 1-1-01 (contract list), 3-1-01 (amendment note), 3-2-01, MH-3a. Confidence 0.80.
**G-320 — throttle-before-KDF order (a) and the 402 zeros are wire-only, never displayed truth (b).** (a) R-55's open risk (login bursts stack ~50 ms scrypt) is bounded ONLY if both throttle checks run BEFORE the derivation — a 429'd or globally-capped attempt must never burn KDF CPU. The plan never states the check order inside the login route. (b) UX acceptance criterion 3 says "limits shown are the store's truth" while the 402 payload hardcodes posts_used=0/posts_limit=0 (byte-stable per D-091/MH-4a) — as written the criterion invites the executor to "improve" the zeros (risk 7) or to render "0/0" as real limits (a lie). Decision: (a) route order is body-validation → global cap → per-email window → scrypt verify → failure-append (pinned: a 429'd attempt performs zero derivations); (b) criterion 3 reworded — the 402 payload carries reason + CTA only; real limits render exclusively from GET /v1/employers/billing/entitlement (the entitlement view is the limits truth source, Task 5-1-01). Applies to: PLAN Task 1-2-02 (a, amendment note), Task 4-2-01 + UX criterion 3 (b, amendment note). Tests: (a) a capped email's login attempt completes in ≪ KDF time / no derivation side-effect observable — pinned as "429 before KDF" via the throttle test's knob math; (b) web source pin: the 402 branch renders reason + CTA and fetches nothing from the payload's posts_used/posts_limit. Confidence 0.80.
## Scope Cuts
None. Every phase maps to a REQ-10-001..005 row; the one candidate (employer draft-edit, the least-essential P4 arm) is named by REQ-10-004 and is cheap once G-318 forces admin-parity — cutting it would unilaterally narrow a locked requirement. Talent pages staying mock is the honest deferral the plan already makes (D-092).
## Advisories (recorded, no plan edit)
- **a-311:** Employer mock-complete "own session" = checkout rows whose employer_id equals the session's claimed entity — including operator-created rows for that entity (same buyer, D-076 fallback path; not a G-301 violation). Pin the test's fixture accordingly so the 403 arm uses a DIFFERENT entity's row, not an admin-created same-entity row.
- **a-312:** Per-email throttle inherently permits targeted lockout DoS (5 failures → victim's email 429s for 15 min). Accepted within D-088 (email-keying is locked, never re-litigated); success-clears-window is the mitigation; surface copy need not mention it.
- **a-313:** .env.example throttle entries are documentation, not "default-off" — the Settings ints are always active with defaults 5/30 (the R-60d shape). Write the comments that way.
- **a-314:** Logout on an already-revoked token → 401 (strict) is fine; do not "fix" it to 204 — idempotent-logout would let a stolen-tab replay confirm revocation state.
- **a-315:** Register's 409 duplicate-email necessarily reveals account existence — standard and unavoidable; the login 401 uniformity (already specified) is the enumeration boundary that matters.
## Plan Edits Applied
1. G-311 → REQUIREMENTS REQ-10-004 wording + PLAN Task 4-1-02 amendment note
2. G-312 → PLAN Tasks 3-3-01, 3-3-02 amendment notes
3. G-313/G-314/G-320a → PLAN Task 1-2-02 amendment note
4. G-315 → PLAN Task 1-1-01 amendment note
5. G-316 → PLAN Task 2-1-02 amendment note
6. G-317/G-318/G-311 → PLAN Task 4-1-02 amendment note (single combined note)
7. G-319 → PLAN Task 3-1-01 amendment note
8. G-320b → PLAN Task 4-2-01 amendment note + UX criterion 3 clause
+41 -38
View File
@@ -2,13 +2,13 @@
## Persona Roster
> **v0.9 update (RESEARCH, lead-developer assessment):** milestone = Payment Engine (Feature — ai_service/billing/ module: provider protocol + mock + stripe impl, 7th D-027 store, /v1/billing routes with HMAC webhooks, posting entitlement enforcement, pricing-page + admin-surface runtime wiring). **Activated:** backend-engineer (owns ai_service/billing/* + api/billing*.py + api/deps.py + main.py router/lifespan wiring — REQ-9-001/003/004/005 core), data-engineer (owns the 7-table billing store schema: idempotency UniqueConstraints, purchase price snapshots, subscription state machine, event dedupe — REQ-9-001/005), security-auditor (phase-specific: webhook HMAC verify + replay window + raw-body discipline, fail-closed secrets, cents money handling, 402 CTA payloads — REQ-9-003), frontend-engineer (owns the pricing-page client conversion + engine-client listBillingCatalog/PaymentRequiredError + packages/types/billing.ts + admin entitlement view — REQ-9-002/004), lead-developer (coordination + wave order + dual-schema pin discipline + cross-boundary wire review). **Deactivated:** cli-engineer (no CLI changes — billing is server+web only), sandbox-engineer, devops-engineer (no infra changes; the existing release lane carries the new code automatically), ai-engineer, identity-engineer, voice-engineer, design-system-engineer.
> **v0.10 update (RESEARCH, lead-developer assessment):** milestone = B2B Employer Network (Feature — ai_service/employers/ module: 8th D-027 store + scrypt auth + session/claim flows, /v1/employers routes, employer web-surface conversion + admin network overview). **Activated:** lead-developer (coordination), backend-engineer (owns ai_service/employers/* + api/employers*.py + api/employers posting/billing routes + the D-091 helper + config.py knobs + main.py wiring — REQ-10-001..005 core), data-engineer (owns the 4-table employer store schema: account/session/claim/audit rows, unique + check-first constraints, tz normalization — REQ-10-001/002), frontend-engineer (owns the employer surface conversion + engine-client employer section + SessionRequiredError + login/register pages + packages/types/employer.ts + admin overview sections + pricing CTA conversion — REQ-10-003/005), security-auditor (phase-specific: scrypt params + compare_digest, session hashing-at-rest, throttle windows, CORS Authorization, 422 password-echo scrub, claim-evidence honesty, sessionStorage exposure, PII — REQ-10-001/002), identity-engineer stays DEACTIVATED (see its note — employer auth is NOT the v0.5 learner identity module). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
### lead-developer
```yaml
active: true
phase_specific: false
reason: Coordinates the v0.9 Payment Engine milestone (foundation → catalog → checkout → enforcement → lifecycle waves); owns wave order + the cross-boundary wire review (billing.ts ↔ base.py dual-schema pins, 402 CTA payload shape) + provider-boundary event normalization consistency (R-43)
reason: Coordinates the v0.10 B2B Employer Network milestone (auth foundation → claim → surface conversion → posting management → checkout waves); owns wave order + the cross-boundary wire review (employer.ts ↔ employers/base.py dual-schema pins, the 402/401/403 discrimination contract) + the D-091 extraction's no-drift review (both principal gates compose the same helper)
domain: coordination
frameworks:
- turborepo
@@ -27,7 +27,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no infra changes; billing rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
reason: v0.10 DEACTIVATED — no infra changes; the employer module rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
domain: devops
frameworks:
- docker
@@ -53,7 +53,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no CLI changes; billing is server+web only. Roster + v0.8 territory retained.
reason: v0.10 DEACTIVATED — no CLI changes; the employer surface is server+web only. Roster + v0.8 territory retained.
domain: cli
frameworks:
- node
@@ -82,7 +82,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
reason: v0.10 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
domain: infra
frameworks:
- python
@@ -106,28 +106,29 @@ territory:
```yaml
active: true
phase_specific: true
reason: v0.9 re-activated (phase-specific) — P3 webhook security review: HMAC-SHA256 verify discipline (raw-body-first, compare_digest, all-v1 iteration for secret rolls, ±300s replay window, non-v1 schemes ignored — R-38..R-40), fail-closed webhook secret (unset → per-boot random → 401s, correct for a payment surface), event dedupe integrity, cents money handling (no floats — D-081), 402 CTA payload honesty, stripe provider key redaction in errors (the embedding test precedent); P6 final review (no secrets in billing code/logs/releases)
reason: v0.10 re-activated (phase-specific) — P1 auth review: scrypt params/verify discipline (N=2^14/r=8/p=1 module constants pinned by test, hmac.compare_digest, self-describing rows so params evolve without invalidating stored hashes — D-086), session tokens hashed-at-rest (sha256(token) only, DB-leak yields nothing — D-087), throttle windows (per-email failed-login + global cap; email-keyed never IP — D-088), 401 vs 403 semantics (credential vs authorization — D-090); P2 claim review: evidence is advisory, never displayed as verification (A-304 — example.com corpus makes match=false corpus-wide), one-account-one-entity UNIQUE enforcement; P3+ the 422 password-echo fix (main.py pii_fields gains "password"+"email" — pydantic 422s echo err.input today, R-56/open-risk-3), sessionStorage exposure copy, employer mock-complete own-session check (G-301 hole class); P6 final review (no secrets/logs/releases leak credentials)
domain: security
frameworks:
- pytest
- httpx
constraints:
- STRIDE-classified
- secrets-via-env-only (D-014; AI_PAYMENT_API_KEY/AI_WEBHOOK_SECRET never logged)
- secrets-via-env-only (D-014)
- secrets-never-logged
- webhooks-fail-closed-when-unconfigured
- raw-body-before-parse (signature verification sees exact bytes)
- fail-closed-on-unconfigured
- tokens-hashed-at-rest (D-087)
- never-display-forgeable-as-verified (D-089/A-304)
territory:
- "apps/ai-service/ai_service/billing/**"
- "apps/ai-service/ai_service/api/billing*.py"
- "apps/ai-service/tests/billing/**"
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/api/employers*.py"
- "apps/ai-service/tests/employers/**"
```
### backend-engineer
```yaml
active: true
phase_specific: false
reason: v0.9 ACTIVATED — owns the billing module core: ai_service/billing/base.py (PaymentProvider protocol + pydantic contracts + canonical event names), mock.py (deterministic Stripe-shaped provider), stripe.py (form-encoded REST via shared httpx pool, Idempotency-Key per R-49), factory.py (G-11 loud fallback), catalog.py (seeded idempotent), api/billing.py + api/billing_admin.py (require_admin dep verbatim R-51, webhook route raw-body-first, entitlement composition into create_posting AFTER the admin gate), config.py trio (payment_provider/payment_api_key/webhook_secret — R-53), main.py router mounts + lifespan store wiring
reason: v0.10 ACTIVATED — owns the employers module core: ai_service/employers/base.py (pydantic contracts), passwords.py (stdlib scrypt KDF, D-086), store.py wiring via deps.py get_employer_store, claim evidence normalization (stdlib suffix match, D-089); api/employers.py (auth routes register/login/logout/me + claim + own-postings CRUD/publish/close + own-checkout + own-entitlement; require_employer_session dep per D-090) + api/employers_admin.py (accounts/claims/audit operator routes, require_admin imported from marketplace_admin — D-094); the D-091 shared consume_posting_entitlement helper + its adoption in marketplace_admin.create_posting (wire contract byte-identical); config.py throttle knobs + .env.example; main.py wiring (employer_store lifespan + close, router mounts, CORS allow_headers gains "Authorization" — R-60)
domain: backend
frameworks:
- fastapi
@@ -140,14 +141,15 @@ constraints:
- secrets-via-env-only (D-014)
- idempotent-scripts
- fail-closed-admin
- never-call-cloud-in-tests (MockTransport byte-contract only, D-075)
- provider-boundary-normalization (R-43: store provider ids verbatim)
- never-call-cloud-in-tests (zero network by construction, D-096)
- gate-before-insert (the 402 consumes nothing, R-58)
- real-kdf-in-tests (no fake-hash path, D-096)
territory:
- "apps/ai-service/ai_service/billing/**"
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/config.py"
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/api/**"
- "apps/ai-service/tests/billing/**"
- "apps/ai-service/tests/employers/**"
- "apps/ai-service/.env.example"
- "apps/ai-service/pyproject.toml"
```
@@ -156,7 +158,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.9 ACTIVATED — owns the pricing-page conversion (server shell retained per A-411/pins + PricingClient component with AbortController + honest loading/error/empty states, the A-406 board-client pattern), engine-client listBillingCatalog() + PaymentRequiredError (402 dict-detail discrimination — parseError precedent), packages/types/billing.ts (dual-schema pinned), admin entitlement view in the moderation surface (per-employer active package/posts used/limit/expiry, client fetch)
reason: v0.10 ACTIVATED — owns the employer surface conversion (D-092): engine-client employer section (register/login/logout/me, claim, own-postings, own-checkout, own-entitlement + SessionRequiredError 401 discrimination + nc-employer-session sessionStorage wrapper — the adminToken pattern), /employer/login + /employer/register pages (greenfield), postings page own-lifecycle conversion, overview page real active-postings + applicant counts (talent metrics stay mock-labeled), employer profile via the existing public GET, packages/types/employer.ts (dual-schema pinned, D-093); admin overview sections on the moderation page (accounts list, claim queue with evidence signal + approve/reject, audit — the entitlement panel's locked-state pattern, D-094); pricing CTA conversion from contact-path to employer-login path (supersedes D-079); talent pages stay mock (D-092)
domain: frontend
frameworks:
- react
@@ -166,19 +168,20 @@ constraints:
- component-first
- server-components-default
- no-build-time-db-reads (D-039 export build stays repo-only)
- honest-states (loading/error/empty never mocked)
- cents-in-types (number documented as cents, D-081)
- honest-states (loading/error/empty/logged-out never mocked)
- abortcontroller-everywhere (A-406)
- session-gated-shell (401 → honest logged-out state, never a retry loop)
territory:
- "apps/web/**"
- "packages/ui/**"
- "packages/types/billing.ts"
- "packages/types/employer.ts"
```
### ai-engineer
```yaml
active: false
phase_specific: false
reason: v0.9 DEACTIVATED — no LLM-facing work; all provider layers ship unchanged.
reason: v0.10 DEACTIVATED — no LLM-facing work; employer flows need no tagging/enrichment/embedding changes (publish re-tag + enrich-on-first-publish compose unchanged behind the employer routes).
domain: ai
frameworks:
- pydantic
@@ -199,7 +202,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.9 ACTIVATED — owns the 7th D-027 store: billing/store.py with the 7-table schema (packages, plans, purchases with price snapshots — D-078, subscriptions state machine, subscription_events with verbatim event_id UniqueConstraint dedupe, checkout_sessions, billing_events audit trail), InvalidTransition domain error, transition matrix tests, purchase consumption query (newest non-expired with remaining posts), idempotent catalog seeding
reason: v0.10 ACTIVATED — owns the 8th D-027 store schema: employers/store.py with the 4-table family (employer_accounts: email UNIQUE + scrypt hash + employer_id UNIQUE-on-approval; employer_sessions: token_hash UNIQUE, expires_at = login + 7-day constant, lazy TTL; employer_claims: pending→approved|rejected + evidence JSON + decided_by/decided_at; employer_audit_events: the BillingEventRow audit shape), the employer_ prefix (bare `employer` is marketplace's table), check-first one-pending-per-entity/one-approved-owner constraints (the insert_application precedent), _validate/_normalize + _as_utc tz discipline, SQLModel.metadata.create_all idempotent compat (D-095), consume-free read paths for accounts/claims/audit lists
domain: data
frameworks:
- sqlmodel
@@ -209,12 +212,12 @@ constraints:
- schema-first
- type-safe
- dual-schema-sync
- idempotency-by-constraint (UniqueConstraint + check-first, the insert_application pattern)
- snapshots-immutable (catalog edits never rewrite purchase history, D-078)
- idempotency-by-constraint (UniqueConstraint + check-first)
- tokens-never-stored-raw (sha256 only, D-087)
- audit-append-only (provenance rows never mutated)
territory:
- "apps/ai-service/ai_service/billing/store.py"
- "apps/ai-service/ai_service/billing/catalog.py"
- "apps/ai-service/tests/billing/test_store.py"
- "apps/ai-service/ai_service/employers/store.py"
- "apps/ai-service/tests/employers/test_store.py"
- "packages/types/**"
```
@@ -222,7 +225,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 custom persona — module shipped complete; no v0.9 identity-domain work.
reason: v0.10 stays DEACTIVATED — the employer auth in this milestone is NOT the v0.5 learner identity module: identity/ is the KYC/age-gate store (IdentityRecord, derived age bands, require_verified_age gates for LEARNERS per D-043); v0.10's employer principals are a THIRD principal class (D-097 — no learner gates on employer routes, the D-043 composition on learner routes untouched). The identity module, its store, and its gates ship unchanged; backend-engineer owns the new employer auth surface. Reactivation is a founder call if/when a real KYC vendor lands or employer SSO/OIDC (explicitly out of scope at pilot) is scheduled.
domain: identity
frameworks:
- fastapi
@@ -241,7 +244,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 territory — voice shipped complete; no v0.9 voice work.
reason: v0.5 territory — voice shipped complete; no v0.10 voice work.
domain: ai-media
frameworks:
- httpx
@@ -262,7 +265,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: No design-token or primitive work in v0.9; roster retained.
reason: No design-token or primitive work in v0.10 (login/register pages reuse existing Input/Button/Card primitives); roster retained.
domain: frontend
frameworks:
- tailwindcss
@@ -278,7 +281,7 @@ territory:
| Persona | Phases | Removed After |
|---------|--------|---------------|
| security-auditor | 1 (runner + secrets), 2 (image non-root/volumes), 3 (deb postinst), 6 (final channel-consistency review) | milestone complete |
| security-auditor | 1 (auth surface: scrypt + session hashing + throttles + CORS + 422-scrub fix), 2 (claim evidence honesty + UNIQUE enforcement), 5 (employer mock-complete own-session + checkout review), 6 (final review: no credentials in logs/releases) | milestone complete |
All other personas span the milestone. Deactivated personas receive no tasks.
@@ -286,9 +289,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
| Conflict | Resolution |
|----------|------------|
| devops-engineer vs cli-engineer (build-binary.mjs invocation) | devops-engineer owns the WORKFLOW steps that invoke `pnpm --filter @nextcraft/cli build:binary`; cli-engineer owns the SCRIPT itself (arch parameter, arm64 base, sidecars) — the workflow never patches build scripts |
| devops-engineer vs sandbox-engineer (D-068 in Dockerfile) | devops-engineer owns the Dockerfile layers (base, user, healthcheck, volume); sandbox-engineer owns the runtime capability probe + degrade semantics the image merely runs; NC_CONTAINER marker name is a shared contract (devops sets it in the entrypoint, cli-engineer + sandbox-engineer read it) |
| cli-engineer vs devops-engineer (doctor container facts) | cli-engineer owns doctor/verify/guide implementations; devops-engineer supplies the documented facts (volume path, privileged variant recipe) via deploy/README.md |
| sandbox-engineer vs backend-engineer (main.py boot notice) | sandbox-engineer owns the D-068 boot notice + api/sandboxes.py degrade arm this milestone; backend-engineer is deactivated but its config.py territory is NOT modified (NC_CONTAINER lives in the CLI layer, not Settings) |
| cli-engineer vs security-auditor (postinst/secrets) | cli-engineer implements; security-auditor reviews fail-never-postinst + token resolution and may patch security defects directly in scripts/build-deb.sh + release-assets.sh (its territory) |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
| backend-engineer vs frontend-engineer (engine-client.ts) | backend-engineer owns the API contract shapes the client consumes; frontend-engineer owns engine-client.ts itself (the employer section, SessionRequiredError, the session wrapper) — the shared `EntitlementResponse`/`PurchaseEntitlement` interfaces in engine-client.ts (lines 730-752) are a frozen v0.9 contract neither side may re-shape; the new employer wire types land in packages/types/employer.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
| backend-engineer vs security-auditor (api/employers.py) | backend-engineer implements the dep/routes/throttles; security-auditor reviews and may patch security DEFECTS directly in api/employers*.py + employers/passwords.py (its territory) — e.g. the 422 password-echo scrub fix in main.py's pii_fields; implementation vs review stays with backend |
| backend-engineer vs data-engineer (employers/store.py) | data-engineer owns the schema + store methods; backend-engineer owns routes/deps/wiring that compose it; passwords.py (KDF core) is backend territory, the password_hash COLUMN contract is data territory |
| backend-engineer vs frontend-engineer (main.py CORS block) | backend-engineer owns the allow_headers edit at main.py:341; security-auditor owns the tests/api/test_cors.py preflight PIN (the E87 regression lesson is a security concern — the pin lives in the security review's changeset) |
| frontend-engineer vs security-auditor (sessionStorage) | frontend-engineer implements the nc-employer-session wrapper; security-auditor reviews the exposure copy + logout-revoke discipline and pins the no-retry-loop locked state (the G-308 pattern); neither patches the other's files — findings flow through lead-developer |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
+132 -337
View File
@@ -1,403 +1,198 @@
# Nextcraft — PLAN.md (v0.9 Payment Engine)
# Nextcraft — PLAN.md (v0.10 B2B Employer Network)
## Overview
**Milestone:** v0.9 — Payment Engine (Feature, D-074): payment processing and subscription management — posting packages (single/bundle/enterprise), talent-access plans, subscription tiers with real checkout, provider webhooks (HMAC-verified, idempotent), posting-creation entitlement enforcement, and a full subscription lifecycle — behind a provider-agnostic `PaymentProvider` protocol, mock-first (D-014 pattern; tests never call Stripe). The pricing page (REQ-017's surface) gains its real backend.
**Tag line:** phase tags advance the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release), continuing from the v0.7.6 milestone release
**Branch:** milestone/v0.9-payment-engine
**Milestone:** v0.10 — B2B Employer Network (Feature, D-085): real employer auth/accounts + employer self-serve — employers become principals: registered accounts with server-side sessions (scrypt-hashed credentials, hashed-at-rest session tokens), claim/verify of their existing v0.6 `EmployerRow` entity (admin-approved at pilot, evidence advisory), the employer dashboard converting from the v0.1 mockup to real data (own-postings lifecycle, real counts, honest mock-labels only where no backend exists), self-serve posting management composing the v0.9 entitlement gate unchanged (D-091 shared helper, 402 wire contract byte-stable), and self-serve checkout over the v0.9 billing catalog (buyer = session employer, superseding D-076; admin operator routes retained as fallback + oversight, D-094 network overview). Mock-first doctrine holds: no external auth provider, tests never call a cloud, zero new dependencies (R-66).
**Tag line:** phase tags advance the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release), continuing from the v0.8.6 milestone release
**Branch:** milestone/v0.10-b2b-employer-network
**Phase list (requirement mapping):**
| Phase | Requirement | Focus |
|-------|-------------|-------|
| P1 | REQ-9-001 | Billing foundation: billing/ module (protocol + mock + store + HMAC + factory + settings), no routes yet |
| P2 | REQ-9-002 | Catalog + pricing surface: seeded catalog, /v1/billing/catalog, pricing-page client conversion, types + pins |
| P3 | REQ-9-003 | Checkout + webhook flow: admin checkout route, stripe provider, public webhook (raw-body HMAC, dedupe, idempotent processing), mock-complete E2E path |
| P4 | REQ-9-004 | Entitlement enforcement: consume_posting in create_posting, 402 CTA payload, admin entitlement view |
| P5 | REQ-9-005 | Subscription lifecycle: state machine via webhooks, manual grant/revoke, billing event log, past_due grace |
| P6 | — | Final review + audit + milestone ship (v0.8.6 = milestone release) |
| P1 | REQ-10-001 | Employer auth foundation: employers/ module (8th D-027 store + stdlib scrypt KDF + hashed session rows + both throttle windows), register/login/logout/me routes, main.py wiring (store + CORS Authorization + 422 scrub) — zero web changes |
| P2 | REQ-10-002 | Employer entity claim: claim route + advisory evidence (stdlib domain match), one account : one entity (UNIQUE + check-first), admin claim review/approve/reject operator routes + audit |
| P3 | REQ-10-003 | Employer account surface: own-postings read route, packages/types/employer.ts + dual-schema pins, engine-client employer section (SessionRequiredError + nc-employer-session), login/register pages, session-gated shell, postings/overview conversion (talent stays mock) |
| P4 | REQ-10-004 | Self-serve posting management: D-091 shared consume_posting_entitlement helper + admin adoption (no drift), employer create/edit/publish/close routes, PostingManager write actions |
| P5 | REQ-10-005 | Self-serve checkout + admin overview: employer checkout + entitlement view + own-session mock-complete through the REAL webhook path, pricing CTAs → employer-login path, admin network overview sections |
| P6 | — | Final review + audit + milestone ship (v0.9.6 = milestone release) |
**Wave order (binding): foundation before surface before flow before enforcement before lifecycle.** P1 builds the module everything else composes (store + protocol + mock — P2's catalog route reads the store, P3's checkout writes it). P2 precedes P3 because checkout references catalog items by id (the catalog must exist + be surfaced before sessions can be created against it). P3 precedes P4 because enforcement consumes entitlements that only the webhook flow grants. P5 needs the full event set the flow produces. Each phase is a vertical slice: store rows → routes → web surface → tests green → ship.
**Wave order (binding): principals before claims before surface before management before monetization.** P1 builds the module everything else composes (store + KDF + session gate — P2's claim route authenticates through it, P3's surface gates on it, P4/P5 parameterize it). P2 precedes P3 because the dashboard's claimed-entity profile and claim-pending states need decided claims to exist. P3 precedes P4 because the postings page conversion consumes the own-postings read route and the P4 mutations wire into the converted page. P4 precedes P5 because checkout's buyer = the session's claimed, managing entity and the upgrade CTA targets the surface P4 completed. Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
**Binding contracts:** clarify decisions D-075..D-084 (PROJECT.md) and research R-33..R-54 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates.
**Binding contracts:** clarify decisions D-086..D-097 (PROJECT.md) and research conclusions R-55..R-66 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided (ARCHITECTURE v0.10): `ai_service/employers/{__init__,base,store,passwords,claim_evidence}.py` — no service.py; `api/employers.py` + `api/employers_admin.py`; `tests/employers/`.
**Environment facts (probe-verified, R-51..R-54):** require_admin dep pattern at api/marketplace_admin.py:46-85; D-027 store family replication (marketplace/store.py the template); Settings env trio precedent (config.py: provider selector + SecretStr key + per-boot-random secret); HMAC precedent telemetry/tokens.py; at-least-once dedupe precedent insert_application; pricing page inline constants at page.tsx:41-111 + metadata pinned by pins.test.ts:189-190 + sitemap STATIC_PAGES; engine-client jsonFetch/parseError at lines 90-140; main.py inline router mounts + lifespan state-injection + G-11 fallback pattern.
**Environment facts (probe-verified, R-55..R-66):** scrypt is stdlib in the shipped venv (3.11.2), ~47 ms/hash at N=2^14/r=8/p=1, 16 MiB working set, default maxmem admits it — never pin maxmem below ~17 MiB (R-55); require_admin extract→verify→return shape at api/marketplace_admin.py:46-85, the 422 scrubber redacts only `date_of_birth` today (main.py:398-424, pii_fields line 401 — open-risk-3) (R-56); the 8th-store recipe fully specified by billing/store.py, bare `employer` table taken by marketplace/store.py:144 (R-57); create_posting gate order + the 402 dict's hardcoded posts_used=0/posts_limit=0 in BOTH arms (marketplace_admin.py:157-251) (R-58); billing_admin.create_checkout source mechanics incl. unique-pending placeholder + G-310 URLs (billing_admin.py:77-146) + the admin mock-complete path (152-227) (R-59); the four main.py wiring sites (lifespan ~182-190, CORS allow_headers line 341, inline router mounts 366-389, Settings-int knob shape) (R-60); web census — engine-client.ts section order (employer appends beside billing at ~717), adminToken() wrapper at 759-767, the pins.test.ts billing.ts block 205-231 + "Contact Sales" pin 250, NO login page in any route group (R-61); moderation page + entitlement-panel locked-state pattern (R-62); both throttle precedents (identity _SubmitRateLimiter 97-116, sandboxes _check_global_create_rate 123-137, reset-fixture pattern tests/marketplace/conftest.py:123-131) (R-63); tests/billing/ layout + state-injection make_client fixture (test_entitlement_enforcement.py:28-59) (R-64); seeded employer websites are example.com placeholders → evidence match=false corpus-wide, stdlib urlsplit suffix-match normalization (R-65); zero new deps, zero workspace changes, two .env.example knobs (R-66).
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to billing + the pricing/admin surfaces.
**Verification commands (phase gates — the repo's own scripts, R-66):**
**Secrets policy (binding):** AI_PAYMENT_API_KEY / AI_WEBHOOK_SECRET env-only (D-014), SecretStr in settings, never logged, never in .env.example with real values; tests use fixed test secrets; the stripe provider redacts keys from exceptions (embedding test precedent).
- `pnpm lint` (turbo lint — eslint across all TS workspaces) and `pnpm ai:lint` (ruff on ai-service)
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides, the D-086 passlib rejection holds)
- `pnpm ai:test:fast` (FAST=1 scripts/test.sh — `-m "not real_server and not userns"` per D-063; tests/employers/ joins the fast lane)
- `pnpm web:test` (node:test incl. pins.test.ts) + `pnpm cli:test` (unchanged surface, regression guard)
- `pnpm --filter web build` (static export, D-039 unchanged) on web-touching phases (P3, P4, P5)
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/employers/, api/employers*.py + the D-091 helper, config.py/main.py wiring, tests/employers/, packages/types/employer.ts, the employer web surface, and the admin overview/pricing CTA surfaces.
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); employer passwords are user data: stored only as self-describing scrypt hashes (D-086), never logged, never returned, never echoed in 422s (pii_fields fix, R-56); session tokens stored ONLY as sha256(token) (D-087 — a DB-file leak yields nothing usable); tests use throwaway credentials against tmp_path DBs; no credential value appears in any committed file, log line, or release asset.
---
## Phase 1: Billing Foundation (REQ-9-001)
## Phase 1: Employer Auth Foundation (REQ-10-001)
**Goal:** The billing module exists and is proven: protocol + mock provider + 7-table store + HMAC webhook verification + factory + settings — all tested, zero routes mounted, zero web changes.
**Goal:** The employers module exists and is proven: 4-table store + stdlib scrypt KDF + hashed server-side sessions + register/login/logout/me routes + both throttle windows + main.py wiring — all tested with the REAL KDF, zero web changes, zero new dependencies.
### Wave 1-1: Module core (backend-engineer + data-engineer, sequential)
### Wave 1-1: Module core — store + KDF + contracts (backend-engineer + data-engineer, sequential)
- **Task 1-1-01** (backend-engineer): `ai_service/billing/__init__.py` + `base.py` — pydantic contracts: `Package` (id, name, price_cents, posts_limit, duration_days, features), `Plan` (id, name, price_cents, interval, features), `CheckoutSessionSpec` (mode, line item ref, success/cancel refs, client_reference_id, metadata), `CheckoutSession` (id, url, status, payment_status, mode, expires_at), `SubscriptionRef`, `CanonicalBillingEvent` (name enum: checkout.completed, invoice.paid, invoice.payment_failed, subscription.cancelled, subscription.renewed + payload dict). Money = int cents ge=0 (D-081). `PaymentProvider` Protocol: create_checkout_session, retrieve_session, cancel_subscription, update_subscription, verify_webhook, normalize_event.
- **Task 1-1-02** (data-engineer): `billing/store.py` — 7th D-027 store (R-52): 7 tables per D-080; UniqueConstraints (subscription_events.event_id, checkout_sessions.provider_session_id, **purchases.checkout_session_id — G-303: grants idempotent per session, not per event id**); purchases carry price/name snapshot columns; `_validate`/`_normalize`/`_as_utc`/`_sqlite_connect` (WAL/NORMAL/busy_timeout/foreign_keys); Protocol + SQLiteBillingStore pair; `InvalidTransition(ValueError)`; store methods: seed_catalog (idempotent upsert), list_catalog, get_purchase/consume_posting(employer_id) (**G-304: single conditional UPDATE `SET posts_used = posts_used + 1 WHERE id = <newest non-expired with remaining> AND posts_used < posts_limit`, rowcount-checked — never check-then-increment; 0 rows → PackageExhausted/NoActivePackage**), subscription get/upsert/transition (state machine per D-083; **upsert keyed on provider subscription id — G-303**), insert_provider_event (check-first dedupe → created flag), insert_billing_event (audit, manual provenance, **unmatched marker per G-302**), close. Tests: `tests/billing/test_store.py` — tmp_path db, table-existence via sqlite_master, pragma pins, IntegrityError surfacing, dedupe idempotency double-insert (**G-303: same session, different event ids → one purchase**), transition matrix (valid + InvalidTransition; **G-305: no `upgraded` status value**), consume_posting exhaustion + expiry + multi-purchase ordering (**G-304: concurrency test — 1 remaining, two connections → exactly one succeeds**).
- **Task 1-1-03** (backend-engineer): `billing/mock.py` — deterministic mock provider (D-075): Stripe-shaped ids (`cs_mock_...` counter+randbits — the identity-mock unique-mint lesson), create/retrieve session (unpaid → paid on confirm), signature mint/verify identical scheme to real provider (whsec-shaped), normalize_event passthrough (mock emits canonical names directly). Tests: `tests/billing/test_mock.py`.
- **Task 1-1-04** (backend-engineer): `billing/webhooks.py` — verification core shared by providers: parse `Stripe-Signature` (t=,v1= pairs, multi-v1, ignore v0/unknown — R-38), HMAC-SHA256 over `{t}.{raw_body}` bytes, hmac.compare_digest, ±300s window with `now` injection (R-40, risk-4). Tests: `tests/billing/test_webhooks.py` — good/bad signature, stale timestamp (outside window), missing header, multi-v1 (secret roll), v0-scheme rejected, replay-same-event accepted (idempotent at the verify layer).
- **Task 1-1-05** (backend-engineer): `billing/factory.py` + config.py trio (R-53): `payment_provider="mock"`, `payment_api_key: SecretStr`, `webhook_secret=""` (per-boot random); UnknownPaymentProviderError; .env.example additions (commented, no real values). Tests: factory selection + unknown error + secret redaction (repr must not leak).
- **Task 1-1-01** (backend-engineer): `ai_service/employers/__init__.py` + `base.py` — pydantic contracts (D-093's list, dual-schema-pinned later in P3): `EmployerAccount` (id, email, employer_id nullable, created_at, `email_verified: Literal[False]` — UNVERIFIED per D-097, displayed as such), `EmployerClaim` (id, account_id, employer_id, status pending|approved|rejected, evidence dict, created_at, decided_by/decided_at), `RegisterEmployerRequest`/`LoginEmployerRequest` (email + password, honest validation), `TokenResponse` (token, expires_at), `EmployerMe` (account + claimed employer_id + claim state). No service.py (R-57) — routes compose the store via deps DI. (G-315: email fields are plain `str` with stdlib-only validation — non-empty, exactly one "@", non-empty domain, stripped + lowercased on normalize; pydantic `EmailStr` is BANNED — email-validator is not in the venv and adding it would be the first new dependency, breaking R-66/D-086.)
- **Task 1-1-02** (data-engineer): `employers/store.py` — 8th D-027 store (R-57, D-095): 4 tables, all `employer_`-prefixed (bare `employer` is marketplace's EmployerRow, marketplace/store.py:144): `employer_accounts` (id PK `acct-`-style, email UNIQUE indexed, password_hash self-describing string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), account_id indexed, created_at, expires_at = login + `SESSION_TTL_DAYS = 7` module constant), `employer_claims` (schema per D-089, built in P2 — table lands now per D-095's one-time create_all), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape, billing/store.py:337-359). Mechanics mirror billing/store.py exactly: `_sqlite_connect` pragmas (WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON), `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol `EmployerStore` + `SQLiteEmployerStore` pair, surfaced IntegrityError; `SESSION_TTL_DAYS` pinned by test (the PAST_DUE_GRACE_DAYS shape, D-087/D-083). Store methods: insert_account (check-first → (row, created)), get_account_by_email, mint_session (token_hash=sha256(token), expires_at=now+TTL, `now` injectable), get_session_by_token_hash (**lazy TTL — expired row reads as absent, G-306, no timer**), revoke_session (DELETE), append_audit_event, close. Tests: `tests/employers/test_store.py` — tmp_path DB, table-existence via sqlite_master (4 employer_ tables on the shared db_path), pragma pins, UNIQUE violations surfaced (duplicate email, duplicate token_hash), **token never stored raw (row holds sha256 only)**, lazy TTL with injected now (login+6d present, +8d absent — no sleeps), revoke → lookup miss.
- **Task 1-1-03** (backend-engineer): `employers/passwords.py` — stdlib scrypt KDF (D-086, R-55): module constants `SCRYPT_N=2**14, SCRYPT_R=8, SCRYPT_P=1, SCRYPT_DKLEN=32` (**pinned by test**); per-password salt `secrets.token_bytes(16)`; stored self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` (params evolve without invalidating rows); verify parses params from the stored string, derives, compares via `hmac.compare_digest`; **no maxmem argument below ~17 MiB** (R-55 probe: default 32 MiB admits the 16 MiB working set; if pinned for explicitness use 33554432); module docstring records the ~50 ms/hash budget + throttle-bounded burst risk (open-risk-1). Tests: `tests/employers/test_passwords.py` — REAL KDF, no fake-hash path (D-096): hash→verify accept, wrong password reject, two hashes of the same password differ (random salt), params-pinned constants test, self-describing round-trip (old-format row with explicit params still verifies), **tests never assert hash bytes — accept/reject behavior only**.
### Wave 1-2: Foundation must-haves
### Wave 1-2: Auth routes + throttles + main.py wiring (backend-engineer)
- P1 runs the full suite (fast lane) green: new tests + existing 670 unaffected (billing module is additive; main.py untouched this phase).
- mypy + ruff green (billing files join [tool.mypy] files set implicitly — same package).
- **Must-have:** mock provider + store + webhooks.py are usable WITHOUT any route — proven by direct-instantiation tests only.
- **Task 1-2-01** (backend-engineer): `api/employers.py` — `require_employer_session` dependency (D-090, R-56: the require_admin extract→verify→return shape, single-header `Authorization: Bearer <token>` via partition + scheme check; sha256(token) hash-lookup; **401** missing/expired/unknown — credential failure; **403** reserved for cross-entity authorization; returns session + account + claimed employer_id; token namespaces cannot collide with admin tokens — one class fails the other's gate) + `deps.py` `get_employer_store` (app.state DI). Routes: `POST /v1/employers/auth/register` (email+password → scrypt hash → insert_account; duplicate email → 409 honest; account UNVERIFIED per D-097 — no learner gates ever composed on employer routes), `POST /v1/employers/auth/login` (verify → mint token → `{token, expires_at}`; bad credentials → 401 honest, no user-enumeration difference in response body beyond the 401), `POST /v1/employers/auth/logout` (revoke: DELETE row + audit event; unknown/revoked token → 401), `GET /v1/employers/auth/me` (session + account + claimed employer_id + unverified email displayed as such). Tests: `tests/employers/test_auth.py` — **register→login→me→logout flow green**; duplicate register 409; wrong password 401; logout revokes (subsequent me → 401); expired token → 401 (injected now); no-token → 401; concurrent sessions per account allowed (two logins, both me-green, one logout leaves the other alive). REAL scrypt throughout (≈2 hashes ≈100 ms/test, R-64 budget).
- **Task 1-2-02** (backend-engineer): throttles (D-088, R-63 — both copy the proven in-tree mechanics, keyed by SUBMITTED EMAIL, never client IP): (a) per-EMAIL failed-login window — module dict[email]→timestamps, `time.monotonic()` slice-trim, **default 5 failures / rolling 15 min (900 s, window seconds parameterized — identity hardcodes 60.0) → 429 with the honest "wait" detail**; failures-only append; **successful login CLEARS the email's window**; (b) global per-min cap on login+register attempts — module `deque[float]`, while-popleft older-than-60s, `len >= settings.employer_auth_attempts_per_min` → 429, **append only after ALL checks pass (a rejected attempt never consumes budget)**. Settings knobs as plain ints (the `identity_submits_per_min` shape, R-60d): `employer_login_failures_per_window: int = 5`, `employer_auth_attempts_per_min: int = 30`; `.env.example` entries beside the AI_ADMIN_TOKEN block (commented, default-off, R-66). Tests: `tests/employers/test_throttle.py` — 5 failures → 429 with wait detail; success clears the email window; global cap → 429; rejected attempts don't consume the global budget; **both limiter module states reset via fixtures (the _reset_apply_rate_window pattern, R-63)**. (G-313: an autouse fixture in a NEW tests/employers/conftest.py clears BOTH limiter module states before/after EVERY employers test — the whole suite runs inside one 60s window and self-429s otherwise; the sanctioned deviation from billing's no-conftest layout, R-64. G-314: the global deque appends every well-formed login+register attempt — FAILED PASSWORDS COUNT; only 422-invalid bodies and the cap's own 429s are exempt, or D-088's credential-stuffing bound is void; the "rejected attempts consume nothing" test is reworded to "422-invalid consumes nothing; failed-password attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s — the stuffing test". G-320a: the login route order is body-validation → global cap → per-email window → scrypt verify → failure-append — a 429'd attempt performs ZERO KDF derivations, pinned.)
- **Task 1-2-03** (backend-engineer): main.py wiring (R-60, all four sites): (a) employer_store lifespan block directly after billing_store (~line 191, same getattr-guard) + `close()` in the finally block; (b) employers router mount beside the billing block (inline-import pattern); (c) **CORS `allow_headers` gains `"Authorization"` (main.py:341) + the `tests/api/test_cors.py` preflight pin lands in THIS task** (the E87 lesson applied before it ships — open-risk-2: without it every cross-origin employer fetch dies preflight in the two-port dev + D-038 network-mode topologies); (d) **pii_fields gains `"password"` + `"email"`** (main.py:401, R-56/open-risk-3 — a pydantic 422 on register would echo the raw password into err["input"] today) + the pin: register with an invalid body → 422 whose detail never contains the submitted password (added to tests/employers/test_auth.py). Tests: test_cors.py Authorization-preflight green; 422-echo pin green.
## Phase 2: Catalog + Pricing Surface (REQ-9-002)
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
**Goal:** The catalog is real and surfaced: seeded packages/plans in the DB, `/v1/billing/catalog` public GET, the pricing page reads it at runtime, wire types pinned both sides.
- **Task 1-3-01** (security-auditor): auth-core review — scrypt params/compare_digest discipline (constants pinned, self-describing rows), session tokens hashed-at-rest (sha256 only — DB leak yields nothing, D-087), throttle windows (email-keyed never IP, both knobs honored), 401 vs 403 semantics (D-090), CORS Authorization pin present, 422 scrub pinned, revoke-on-logout. Findings recorded; P0s fixed in-phase.
- **Task 1-3-02** (lead-developer): P1 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` (zero new mypy overrides) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing suites unaffected — the module is additive, marketplace/billing/identity untouched); D-097 discipline check (no require_verified_age/allowlist composed on any employer route); test counts recorded.
### Wave 2-1: Backend catalog route (backend-engineer)
## Phase 2: Employer Entity Claim (REQ-10-002)
- **Task 2-1-01** (backend-engineer): `billing/catalog.py` — the seed constant (single $49/1 post/30d, bundle $399/10 posts/90d, enterprise custom; starter $99/mo, pro $299/mo, enterprise-talent custom — REQ-017's tier structure) + idempotent seed into store. Mounted via `api/billing.py`: `GET /v1/billing/catalog` → `{packages: [...], plans: [...]}` (public — catalog is advertising, not secret; CORS-existing policy applies). Deps: `get_billing_store` (R-51/R-52 wiring: deps.py + main.py lifespan + close in finally). Tests: `tests/billing/test_api.py` — catalog returns the 6 seeded items, shape pinned, unauthenticated 200.
**Goal:** An authenticated account claims an existing EmployerRow entity; claims are pending with recorded advisory evidence; admin approve/reject activates ownership (one account : one entity); the operator queue and audit trail are real.
### Wave 2-2: Web pricing conversion (frontend-engineer)
### Wave 2-1: Claim core — evidence + store + route (backend-engineer + data-engineer)
- **Task 2-2-01** (frontend-engineer): `packages/types/billing.ts` — Package/Plan/CatalogResponse types (cents documented, D-081); dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline: field-for-field vs base.py).
- **Task 2-2-02** (frontend-engineer): `engine-client.ts` — `listBillingCatalog()` via jsonFetch; page stays server shell (metadata + pins untouched — A-411); new `components/marketplace/pricing-client.tsx` ('use client', AbortController, loading/error/empty states — board-client pattern); CTA copy per D-079 (contact path + operator-mediated note; no fake purchase buttons). Tests: fetch-stub catalog render, error state render, empty catalog render, cents formatting.
- **Task 2-2-03** (frontend-engineer): pins updated: pricing-page metadata pin retained; new catalog wire pins added.
- **Task 2-1-01** (backend-engineer): `employers/claim_evidence.py` — stdlib registrable-domain normalization (R-65): `urllib.parse.urlsplit(website).hostname` lowercased ("https://example.com/openai" → "example.com"), email domain after the last "@" lowercased, `match` = simple SUFFIX match (equal, or one endswith "." + other) — no PSL library, no tldextract (zero-dep discipline); module docstring records the multi-label-suffix over-match as acceptable for an advisory signal; evidence shape per D-089: `{account_email_domain, entity_website_domain, match}`. Tests: `tests/employers/test_claim_evidence.py` — normalization table (scheme/path stripped, case-folded), match true on same registrable domain, false on the example.com corpus (R-65).
- **Task 2-1-02** (data-engineer): store claim methods (the insert_application check-first precedent, marketplace/store.py:341-347): `insert_claim` (**one PENDING claim per entity → created=False/409; one APPROVED owner per entity; rejection frees the entity for a new claim**), `decide_claim` (approve → set `employer_accounts.employer_id` — the UNIQUE one-account-one-entity constraint — + decided_by/decided_at + audit append; reject → decided_by/decided_at + audit append), `list_claims` (status filter, consume-free read path for the admin queue), `get_claim`. Tests: `tests/employers/test_claims_store.py` — second pending claim on an entity rejected; claim after rejection succeeds; approve sets employer_id + writes an audit row; second approve by another account → IntegrityError surfaced (one approved owner); decided_at/decided_at provenance pinned. (G-316: the pending-arm uniqueness is TOCTOU on check-first alone — `employer_claims.__table_args__` gains a partial UNIQUE index `uq_pending_claim_per_entity` on employer_id `sqlite_where status='pending'` (SQLite partial index, emitted by create_all — the D-095 one-time path); insert_claim keeps the check-first 409 fast-path and SURFACES the IntegrityError as the same honest 409 under race; the approved-owner arm is already structurally sound via employer_accounts.employer_id UNIQUE. Pinned: the partial index exists in sqlite_master; a raced second pending insert → 409, never a duplicate pending row.)
- **Task 2-1-03** (backend-engineer): `POST /v1/employers/claims {employer_id}` (session-gated via require_employer_session): entity must exist in the marketplace store (404 unknown — claims target the v0.6 ingested EmployerRow data), account must not already hold an entity (409 honest — one account : one entity), evidence computed AT CLAIM TIME + stored as provenance JSON on the claim row, response = pending claim with evidence. **Auto-approval is deferred: the operator decision activates (D-089 — without email verification the match is forgeable; A-304: never present unverified as verified).** Tests: `tests/employers/test_claims.py` — claim → pending with the exact evidence dict; unknown entity 404; already-owner 409; second pending claim on the same entity 409; unauthenticated 401.
## Phase 3: Checkout + Webhook Flow (REQ-9-003)
### Wave 2-2: Admin claim review routes + security review (backend-engineer + security-auditor)
**Goal:** The full payment flow: admin creates a checkout session (mock by default, stripe via env), the provider completes it, the webhook (HMAC-verified, deduped, idempotent) grants the entitlement — proven E2E in tests with zero cloud calls.
- **Task 2-2-01** (backend-engineer): `api/employers_admin.py` — `require_admin` imported from marketplace_admin (the billing_admin.py:27 precedent, D-094): `GET /v1/employers/admin/accounts` (email, claimed entity, created_at, unverified marker), `GET /v1/employers/admin/claims` (queue: status, evidence, domain-match signal, decided state), `POST /v1/employers/admin/claims/{id}/approve`, `POST .../claims/{id}/reject` (set decided_by/decided_at + append employer_audit_events per D-089), `GET /v1/employers/admin/audit` (queryable trail). Fail-closed 403 on no/missing/invalid token (the R-56 matrix). Tests: `tests/employers/test_claims_admin.py` — **approve → account.employer_id set + audit row + claim approved**; reject frees the entity (a new claim from another account succeeds); decided twice → 409 honest; 403 fail-closed on all five routes; audit query returns claim decisions with operator provenance.
- **Task 2-2-02** (security-auditor): claim-evidence review — evidence is advisory, never displayed as verification (A-304; the example.com corpus makes match=false corpus-wide, R-65/open-risk-6); one-account-one-entity UNIQUE enforced at the store (approve path surfaces IntegrityError, never silently overwrites); claim decisions audit-complete (decided_by/decided_at + events); the 409 paths don't leak account existence beyond the authenticated principal. Findings recorded; P0s fixed in-phase.
### Wave 3-1: Stripe provider (backend-engineer)
## Phase 3: Employer Account Surface (REQ-10-003)
- **Task 3-1-01** (backend-engineer): `billing/stripe.py` — form-encoded REST via the shared httpx pool: create session (mode, line_items[0][price_data] from catalog item snapshot, client_reference_id, Idempotency-Key uuid4 — R-33/R-35/R-49), retrieve session, cancel_subscription (DELETE or cancel_at_period_end — R-46), update_subscription (items[0][id] resolution inside provider — R-47 dual-charge guard), verify_webhook (shared webhooks.py), normalize_event (R-43 mapping). Tests: `tests/billing/test_stripe_provider.py` — MockTransport byte-contract: exact form-encoded body, Basic auth header, Idempotency-Key present, item-id in update body, key redaction in exceptions, normalize_event mapping table (Stripe → canonical).
- **Task 3-1-02** (backend-engineer): main.py factory wiring: provider from settings (mock default), G-11 loud fallback on factory error (voice precedent), provider on app.state + deps.
**Goal:** The employer surface is real: own-postings read route feeding a converted dashboard (login/register pages, session-gated shell, own-postings lifecycle view, real counts), wire types dual-schema-pinned, 401 → honest logged-out state — talent pages stay mock, honestly labeled.
### Wave 3-2: Checkout + webhook routes (backend-engineer)
### Wave 3-1: Own-postings read route (backend-engineer)
- **Task 3-2-01** (backend-engineer): `api/billing_admin.py` — `POST /v1/billing/admin/checkout` (require_admin verbatim — R-51): body {item_id, employer_id} → catalog item lookup → create session (operator_ref + employer_id recorded, D-076; **success_url/cancel_url default from public_base_url per G-310: success = {public_base_url}/marketplace/pricing?checkout=success, cancel = …?checkout=cancelled; loud G-11 boot warning when stripe + public_base_url unset**) → persist checkout_sessions row → return {session_id, url}. Tests: fail-closed 403 (no/missing token), 404 unknown item, 201 with persisted row, **byte-contract: success_url + cancel_url present in the form body**.
- **Task 3-2-02** (backend-engineer): `api/billing.py` webhook — `POST /v1/billing/webhook`: `await request.body()` FIRST (risk-1 raw-body discipline), verify HMAC (401 on bad/missing signature), parse event, insert_provider_event (dedupe: existing → 200 already-processed), process by canonical name — **G-302: event payloads are untrusted ID references — the session/subscription resolves from LOCAL rows (creatable only via the admin route); unknown target id → recorded as an unmatched billing_event, NO entitlement, 200; purchase price/name from the stored snapshot, never the event body** — checkout.completed → verify predicate status=complete AND payment_status=paid (R-37) against the local session row → create purchase/subscription from session snapshot; invoice.paid → extend/renew; invoice.payment_failed → past_due; subscription.cancelled → cancelled. All transitions via store (InvalidTransition tolerated-as-duplicate, recorded; **G-306: unknown-target events never 500 — recorded unmatched, 200**). 200 fast. Tests: `tests/billing/test_webhook_route.py` — unsigned 401, bad sig 401, valid + duplicate (idempotent 200, single entitlement), out-of-order tolerated, grace window pinned, mock flow E2E, **G-302: signed event for unknown session → 200 + zero purchases + one unmatched row; inflated payload amount → price = snapshot**, **G-303: distinct event ids same session → one purchase**.
- **Task 3-2-03** (backend-engineer): mock-complete path (D-082, **amended by G-301**): `POST /v1/billing/mock/complete` mounted ONLY under provider=mock **AND composed with require_admin — the operator completing a session after out-of-band payment is the honest pilot flow (G-301: mock is the default provider, so this route exists on every default-config deployment; an unauthenticated completion route on a payment surface is a hole regardless of id unguessability)** — mints a signed event through the REAL webhook processing (direct handler call, same code path) — E2E proof: admin checkout → admin-gated mock complete → webhook → entitlement exists. Route 404s under stripe (factory-conditional mount, test-pinned). Tests: **no token → 403 under mock (G-301)**; 404 under stripe; E2E green.
- **Task 3-1-01** (backend-engineer): `GET /v1/employers/postings` (session-gated; **unclaimed account → 403 + claim-CTA detail** — the D-090 403 semantics; claimed → own postings only, resolved from the marketplace store by the session's employer_id, status facets draft|published|closed, **per-posting applicant_count from the applications table** — the D-092 overview data source). Reuses marketplace store via deps DI; no new tables. Tests: `tests/employers/test_postings_read.py` — own-entity rows only (a second claimed employer's postings never appear); unclaimed 403 with claim-CTA; status facets pinned (counts per status); applicant_count matches seeded applications; no-token 401. (G-319: the response is a NAMED pydantic contract in employers/base.py — `OwnPostingSummary` (PostingSummary reused + applicant_count) + the facets/list response — so the MH-3a dual-schema pin is schema↔schema, not a prose payload; the counts ride a new in-pattern marketplace store read `count_applications_by_posting(posting_ids) -> dict[str, int]`, never a per-posting N+1; employer-facing responses expose applicant COUNTS ONLY — learner identifiers NEVER appear in any /v1/employers/* response, PII boundary, security-auditor P3 scope.)
## Phase 4: Entitlement Enforcement (REQ-9-004)
### Wave 3-2: Wire types + engine-client employer section (frontend-engineer)
**Goal:** Posting creation enforces packages honestly; the admin surface shows entitlement state.
- **Task 3-2-01** (frontend-engineer): `packages/types/employer.ts` (the billing.ts precedent, R-61/R-66) — EmployerAccount, EmployerClaim (+evidence), token/me shapes, own-postings list response (PostingSummary REUSED from marketplace.ts:99-117, never duplicated; entitlement view reuses the v0.9 shapes per D-093) + `export * from './employer'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline; the billing.ts pin block 205-231 is the template): **the employer.ts pin block asserts every field field-for-field against ai_service/employers/base.py's pydantic contracts — both schemas**.
- **Task 3-2-02** (frontend-engineer): `engine-client.ts` employer section (appended beside billing, R-61 — ONE client module, no fork): `registerEmployer`/`loginEmployer`/`logoutEmployer`/`getEmployerMe`/`claimEmployerEntity`/`listOwnEmployerPostings` + the `nc-employer-session` sessionStorage wrapper (the adminToken() 759-767 pattern: window-guarded get/set/clear; tab-scoped bounds XSS exposure — open-risk-4, documented in surface copy) + **`SessionRequiredError` — the 401 discrimination arm joining the parseError chain (no 401 arm exists today, R-61); 401 → honest logged-out state, NEVER a retry loop** + logout clears storage AND calls the revoke route + AbortController on every fetch (A-406). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError; token attached as Authorization header on every employer fetch; logout clears storage + fires revoke.
### Wave 4-1: Enforcement composition (backend-engineer)
### Wave 3-3: Pages — login/register + dashboard conversion (frontend-engineer)
- **Task 4-1-01** (backend-engineer): `api/marketplace_admin.py` create_posting composes billing AFTER require_admin: consume_posting(employer_id) → success: proceed (posts_used incremented); `PackageExhausted`/`NoActivePackage` (billing domain errors) → `HTTPException(402, detail={reason, posts_used, posts_limit, upgrade_cta: {catalog_hint}})` (dict-detail precedent — require_verified_age). Backward-compat: employers with UNLIMITED manual grants bypass (enterprise — D-080). **G-309: the enforcement exists ONLY in the admin route — run_ingest and any future adapter bypass consume_posting by design (seed ingestion is never entitlement-gated)**. Tests: 402 on no package, 402 on exhausted, 201 + consumption on valid, unlimited bypass, expired package degrades to 402, **seeded ingest on a zero-purchase store → all postings upserted, zero 402s (G-309)**.
- **Task 4-1-02** (backend-engineer): `GET /v1/billing/admin/entitlements/{employer_id}` — active package, posts used/limit, expiry, subscription state (require_admin). Tests: shape + fail-closed.
- **Task 3-3-01** (frontend-engineer): `/employer/login` + `/employer/register` pages (greenfield client pages, R-61 — no login page exists in any route group; existing Input/Button/Card primitives, no design-token work): honest states throughout — 401 invalid-credentials, 409 duplicate email, 429 throttle wait, 422 validation (never echoing the password — server-side pinned already); register success → logged-in dashboard; login stores the session token. Tests: fetch-stub render tests — loading/error/empty honest, 429 copy, register→login handoff. (G-312: fetch-stub + source-pin discipline — no render harness, per D-059.)
- **Task 3-3-02** (frontend-engineer): employer dashboard conversion: postings page drops the `jobs` mock import + PostingManager's derived mock statuses/applicants (components/employer/posting-manager.tsx:29-37) → own-postings read lifecycle view (draft → published → closed visible, applicant counts real; write actions land P4); overview page drops `candidates, employers` mock imports + inline METRICS/PIPELINE constants (page.tsx:10, 18-74) → real active-postings count + per-posting applicant counts from listOwnEmployerPostings + claimed-entity profile via the existing public GET /v1/marketplace/employers/{id} (enrichment retained); **talent-match/placement metrics STAY mock-labeled (D-092 — no backend computes them); talent search + candidate profile pages keep their mock imports unchanged (no candidate backend exists — honest out-of-scope)**. Session-gated shell: no/expired session → honest logged-out state with login CTA (SessionRequiredError path), never a retry loop; claim-pending state visible (claim submitted, awaiting review). Tests: web render tests — logged-out state, claim-pending state, postings lifecycle render, real counts; **pins: postings/overview pages grep-clean of the removed mock imports; talent pages' mock imports retained**; `pnpm --filter web build` (static export) green. (G-312: "render tests" = the D-059/E87 discipline — fetch-stub tests over engine-client employer functions + SOURCE pins for component honest states (logged-out/claim-pending/loading/error branches present in source, the pins.test.ts discipline); NO jsdom/happy-dom render harness exists in-tree and none may be added this milestone.)
### Wave 4-2: Admin surface (frontend-engineer)
## Phase 4: Self-Serve Posting Management (REQ-10-004)
- **Task 4-2-01** (frontend-engineer): admin moderation surface gains per-employer entitlement panel (**G-308 design, replacing the A-406 citation — the entitlements route is admin-gated, A-406 is the public-route pattern**): a small operator-token input persisted to sessionStorage + a new `adminFetch` wrapper in engine-client (X-Admin-Token header, token never logged); 403 renders an honest locked state with the token prompt; panel shows active package, posts used/limit bar, expiry, subscription state badge, manual-grant pointer. Tests: fetch-stub render, 402-CTA payload rendering, **stubbed 403 → locked-state render with prompt (G-308)**, **fetch stub asserts the X-Admin-Token header**, no admin-token string in non-test web source beyond the wrapper.
**Goal:** Employers manage their own postings end-to-end: the v0.9 entitlement gate extracted ONCE into a shared api/ helper, both principal gates composing it with byte-identical wire contracts; employer create/edit/publish/close routes; the converted PostingManager gains write actions with honest 402/403 states.
## Phase 5: Subscription Lifecycle (REQ-9-005)
### Wave 4-1: Shared entitlement helper + employer posting routes (backend-engineer + lead-developer)
**Goal:** Full lifecycle: state machine driven by webhooks + manual admin actions, audit log complete, past_due grace honest.
- **Task 4-1-01** (backend-engineer): the D-091 extraction: `consume_posting_entitlement(store, employer_id, upgrade_cta)` in api/ beside both callers (no billing/→api/ boundary violation) — extracted from marketplace_admin.create_posting lines 181-220; **canonical 402 dict `{reason, posts_used: 0, posts_limit: 0, upgrade_cta}` — posts_used/posts_limit hardcoded 0 in BOTH arms preserved EXACTLY (R-58 — the wire contract; UpgradeRequiredError consumes reason + upgrade_cta unchanged, D-080/D-084)**; store-missing → 503 fail-closed (the getattr guard); **gate runs BEFORE insert — a 402 consumes nothing, creates nothing**; `consume_posting` itself (G-304 single conditional UPDATE) UNTOUCHED. marketplace_admin.create_posting adopts the helper with the admin-checkout CTA — behavior byte-identical. Tests: **tests/billing/test_entitlement_enforcement.py green UNCHANGED (the no-drift proof)** + a new payload-parity test: admin-gate 402 vs employer-gate 402 assert equal reason/posts_used/posts_limit with only upgrade_cta differing per principal.
- **Task 4-1-02** (backend-engineer): employer posting write routes (session gate → claim check → entitlement → insert, mirroring the "admin gate still first" composition, R-58): `POST /v1/employers/postings` (create draft — `consume_posting_entitlement(billing_store, session.employer_id, employer-CTA)`; 403 + claim-CTA unclaimed), `PATCH /v1/employers/postings/{id}` (**own drafts only — published/closed → 409 honest; another entity's posting → 403, the D-090 authorization arm; unknown → 404**), `POST .../postings/{id}/publish` (own draft → published + posted_at; the tagging/enrichment composition behind publish unchanged), `POST .../postings/{id}/close` (own posting → closed). Admin posting routes remain untouched (operator path unchanged, provenance-recorded). Tests: `tests/employers/test_postings_write.py` — create consumes from the employer's OWN package (posts_used+1 pinned); 402 both arms via direct store grants (the grant_posts precedent, test_entitlement_enforcement.py:70-85 — no package → `no_active_package`, exhausted → `posting_limit_reached`); **402 consumes nothing (gate-before-insert pinned: failed create leaves posts_used unchanged and no row)**; cross-entity 403; edit published → 409; publish/close lifecycle + applicant counts accrue; unlimited/enterprise manual grants bypass. (G-311: the session/claim gates compose BEFORE the entitlement check — unauthenticated → 401, unclaimed → 403, never a 402 (the R-58 admin-gate-first order; REQ-10-004's "AFTER" wording is corrected by this grill). G-317: employer create stamps its OWN provenance — `source="employer"` with `emp-` id/external_id schemes, never the admin route's `source="admin"` (authorship must stay answerable across seeded/admin/employer classes); the admin routes are untouched. G-318: employer edit composes the IDENTICAL update_posting_fields + tag_posting re-tag path as the admin edit (D-052 — tags are derived, overwritten on edit; tag failure honest, edit stands) with the own-drafts guard evaluated BEFORE any mutation — edit published/closed → 409 with no field/tag side effects. Pinned: employer-created posting carries the employer source stamp; employer edit re-tags on a mock provider; edit published → 409 AND nothing mutated.)
- **Task 4-1-03** (lead-developer): D-091 no-drift review — both principal gates compose the SAME helper (source-level: no duplicated gate logic in either route); the 402 payload-parity test green; v0.9 billing/marketplace_admin suites green unmodified; wire contract (UpgradeRequiredError) unchanged.
### Wave 5-1: Lifecycle + admin routes (backend-engineer + data-engineer)
### Wave 4-2: PostingManager write actions (frontend-engineer)
- **Task 5-1-01** (data-engineer): store transition matrix complete: trialing→active, active→past_due, past_due→active (recovery), past_due→expired (**G-306: lazily computed from past_due_at + 7-day constant at read/entitlement time — no background timer; test-pinned D-083**), active→cancelled, cancelled terminal; **G-305: no `upgraded` status — upgrade is a plan_id transition within `active`, recorded as a billing_event with provenance**; expires_at extension idempotent on invoice.paid; manual grant/revoke/complement methods (source="manual" + operator provenance in billing_events). Tests: full matrix + grace-elapsed expiry (**injected now: past_due_at+6d → granted, +8d → expired — no sleeps**) + manual-action audit rows + **upgrade keeps status=active, changes plan_id, writes billing_event (G-305)**.
- **Task 5-1-02** (backend-engineer): `api/billing_admin.py` lifecycle routes: POST admin/subscriptions/{id}/cancel, /resume, /upgrade (provider calls for stripe; state transitions for mock), POST admin/employers/{id}/grant (manual unlimited/package), /revoke, GET /v1/billing/admin/events (queryable audit log: by employer, by type, by time). Tests: each route happy path + fail-closed 403 + InvalidTransition 409.
- **Task 5-1-03** (backend-engineer): webhook subscription.renewed handling: renewal invoice.paid extends expires_at idempotently (event dedupe makes double-fire safe — R-45); corroborating customer.subscription.updated recorded. Tests: double-renew single-extension.
- **Task 4-2-01** (frontend-engineer): PostingManager gains the write actions over the engine-client employer section: create draft, edit own draft, publish, close — honest states: **402 → UpgradeRequiredError renders the upgrade CTA with the correct reason (the CTA routes to the employer billing surface, P5)**; 403 claim-CTA renders the claim prompt; 409/404 honest; optimistic-free, loading/error/empty per component state; AbortController everywhere. Tests: web fetch-stub — 402 CTA render with reason, publish/close dispatch, own-draft-only error copy, claim-prompt on 403. (G-320b: the 402 branch renders reason + upgrade CTA ONLY — the payload's posts_used/posts_limit are byte-stable zeros (D-091/MH-4a) and are NEVER rendered as limits; real limits render exclusively from GET /v1/employers/billing/entitlement; pinned by source pin.)
## Phase 6: Final Review + Ship (milestone release, v0.8.x line)
## Phase 5: Self-Serve Checkout + Admin Overview (REQ-10-005)
**Goal:** Multi-persona review of all v0.9 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.8.6 (final v0.8.x patch = milestone release), full asset matrix + registry image via the release lane (D-064), milestone completion commits, branch cleanup.
**Goal:** The B2B loop closes: employer-principal checkout over the v0.9 catalog through the REAL webhook path (buyer = session's claimed entity), own entitlement view, pricing CTAs → employer-login path, and the admin moderation page gains the network overview (accounts, claim queue, audit).
- Review: P0 fixes in-phase; P1+ fixed here, never loop back to EXECUTE.
- Audit: git log ↔ .ciagent/ reconstruction; file discipline; branch hygiene; commit discipline (feat: scoped to billing + pricing/admin surfaces).
- Ship: phase/(N+1) branch → milestone → main; delete all milestone branches (tags preserve history).
- Complete: REQUIREMENTS.md (REQ-9-001..005 complete), ROADMAP.md milestone entry, checkpoint cleared.
### Wave 5-1: Employer checkout + entitlement view + mock-complete (backend-engineer)
- **Task 5-1-01** (backend-engineer): `POST /v1/employers/billing/checkout {item_id}` (R-59 verbatim transpose of billing_admin.create_checkout — only principal derivation + metadata provenance change): session gate → **unclaimed → 403 + claim-CTA** → catalog lookup 404 → mode = subscription iff kind==plan → success/cancel URLs from `settings.public_base_url` (G-310) → `insert_checkout_session` FIRST with unique pending placeholder (`pending-{uuid4().hex[:16]}`) → CheckoutSessionSpec with metadata employer_id = claimed entity, operator_ref → the account's provenance (D-076's "operator" becomes account provenance) → provider.create_checkout_session via app.state.payment_provider → bind_provider_session_id → CheckoutResponse. Plus `GET /v1/employers/billing/entitlement` (own purchases/subscriptions: current package, posts used/limit, expiry — honest states; the v0.9 shapes reused). The webhook route + _grant_entitlement serve employer checkouts verbatim (G-303 per-session exactly-once holds). Tests: `tests/employers/test_checkout.py` — unclaimed 403 claim-CTA; 404 unknown item; 201 shape + persisted row; metadata provenance pinned (employer_id = claimed entity, account ref recorded); entitlement view honest states (none/active/expired).
- **Task 5-1-02** (backend-engineer): employer mock-complete arm (D-082 transposed, R-59): `POST /v1/employers/billing/mock/complete` mounted ONLY under provider=mock (factory-conditional), **employer-SESSION-gated, completing one's OWN session — cross-session completion → 403 (the G-301 hole class, open-risk-5); provider_session_id resolved from LOCAL rows by the session's own checkout rows (G-302 — payloads are untrusted ID references)**; mints the signed event through the REAL webhook path (mint_event → process_webhook_event, direct handler call — the D-082 proof shape); 404 under stripe (test-pinned). Tests: `tests/employers/test_checkout_e2e.py` — **E2E: register → login → claim → admin approve → checkout → own mock-complete → webhook → purchase exists with posts available**; cross-session completion → 403; stripe-mode mount → 404; duplicate complete falls through already-complete (no double grant, G-303).
### Wave 5-2: Employer billing surface + pricing CTAs + admin network overview (frontend-engineer)
- **Task 5-2-01** (frontend-engineer): employer billing surface + pricing CTA conversion: the dashboard gains the entitlement view (current package, posts used/limit bar, expiry — honest empty/expired states) + buy flow (catalog from the public `/v1/billing/catalog`, own-checkout trigger, **in-app mock-complete confirmation honestly labeled as the pilot mock path**); **pricing-client.tsx CTAs convert from contact-path to employer-login path (superseding D-079) — the "Contact Sales" enterprise pin at pins.test.ts:250 STAYS (R-61)**; the operator-mediated copy is replaced by the self-serve truth with the admin-fallback noted honestly. Tests: web fetch-stub — entitlement render, checkout dispatch, mock-complete confirmation copy, CTA → /employer/login path, enterprise Contact Sales retained.
- **Task 5-2-02** (frontend-engineer): admin network overview (D-094, R-62): the moderation page (app/(admin)/admin/moderation/page.tsx) gains sibling `<section>` blocks below the v0.9 EmployerEntitlementPanel: employer accounts list (email, claimed entity, entitlement state columns), claim queue (**evidence + domain-match signal rendered ADVISORY — never as verification, A-304**; approve/reject actions), audit view — fed by the P2 operator routes via engine-client admin transport over the existing `adminToken()` G-308 wrapper; **403 renders an honest locked state with the token prompt, never a retry loop (the entitlement-panel load() pattern, R-62)**. Tests: web fetch-stub — locked state on 403, claim queue renders evidence + advisory signal, approve/reject dispatch, accounts list columns.
### Wave 5-3: P5 security review (security-auditor)
- **Task 5-3-01** (security-auditor): checkout surface review — employer mock-complete own-session check (cross-session 403 pinned; mock-only mount pinned; stripe-mode 404 pinned — the G-301 class closed), unclaimed checkout 403 claim-CTA honest, entitlement view leaks nothing cross-entity, sessionStorage exposure copy present on the dashboard (open-risk-4), pricing copy honest (self-serve real, mock path labeled, enterprise pin retained). Findings recorded; P0s fixed in-phase.
## Phase 6: Final Review + Ship (milestone release, v0.9.x line)
**Goal:** Multi-persona review of all v0.10 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.9.6 (final v0.9.x patch = milestone release), full asset matrix + registry image via the existing release lane (no infra changes — the module rides D-064/D-065), milestone completion commits, branch cleanup.
### Wave 6-1: Multi-persona final review (lead-developer + security-auditor)
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the D-091 no-drift verdict holds end-to-end (one helper, two gates, byte-identical 402), the cross-boundary wire review (employer.ts ↔ employers/base.py pins, the 401/402/403 discrimination contract in engine-client), D-097 discipline (learner gates untouched on learner routes; zero on employer routes), the D-092 honest-surface census (talent mock labels intact, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase, never loop back to EXECUTE.
- **Task 6-1-02** (security-auditor): final review — no credentials in logs/releases/assets; scrypt params + self-describing rows pinned; sessions hashed-at-rest; 422 scrub covers password+email; throttle windows active; audit trail complete (claim decisions, grants, revocations all provenance-recorded); no secret ever echoed by an exception path.
### Wave 6-2: Audit + ship (lead-developer)
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the R-66 addition census: ai_service/employers/, api/employers*.py, the api/ helper + adoption, config.py knobs, main.py wiring, tests/employers/, .env.example, packages/types/employer.ts, engine-client section, employer pages, admin sections, pricing CTA; branch/commit discipline — feat: scoped to the employer surface) + ship: merge milestone → main; tag v0.9.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-10-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
- **P1 (REQ-9-001):** billing module importable + tested standalone; store tests green (7 tables, dedupe, transitions); webhooks.py verify matrix green; factory + settings trio live; fast suite green; NO routes mounted, NO web changes. **MH-1a** sqlite_master shows 7 billing tables. **MH-1b** duplicate insert_provider_event returns created=False. **MH-1c** bad-signature verify raises. **MH-1d** factory: mock default, unknown raises, key redacted.
- **P2 (REQ-9-002):** catalog route serves 6 seeded items; pricing page renders from runtime fetch with honest states; dual-schema pins green; metadata pin retained. **MH-2a** GET /v1/billing/catalog unauth 200 with exactly the seeded items. **MH-2b** pins.test.ts billing section green. **MH-2c** pricing page: no inline JOB_PLANS/TALENT_PLANS constants remain (grep-clean).
- **P3 (REQ-9-003):** stripe provider byte-contract green under MockTransport; webhook 401/200/idempotent matrix green; mock E2E green (checkout → complete → entitlement). **MH-3a** unsigned webhook → 401, never processed. **MH-3b** duplicate event → 200, single entitlement; **distinct event ids, same session → one purchase (G-303)**. **MH-3c** E2E: purchase exists after admin-gated mock complete with predicate honored; **no-token complete → 403 (G-301)**. **MH-3d** update body contains items[0][id]. **MH-3e** no test touches the network (MockTransport-only, grep-pin). **MH-3f** signed event for unknown session → 200, zero entitlement, unmatched row (G-302).
- **P4 (REQ-9-004):** create_posting enforces; 402 payload shape pinned; admin entitlement view renders. **MH-4a** no-package posting → 402 with upgrade_cta. **MH-4b** exhausted → 402; valid → 201 + posts_used incremented. **MH-4c** unlimited bypass. **MH-4d** expired degrades to 402.
- **P5 (REQ-9-005):** transition matrix complete + grace pinned; manual actions audited; renewal idempotent; event log queryable. **MH-5a** grace-elapsed past_due → expired (7-day constant pinned, lazy evaluation per G-306 — no sleeps); **no `upgraded` status value exists (G-305)**. **MH-5b** double-renew single extension. **MH-5c** manual grant → billing_events row with source=manual. **MH-5d** GET admin/events filters by employer.
- **P1 (REQ-10-001):** the employers module is importable + tested standalone; register/login/logout/me live with hashed sessions + both throttles; main.py wired; zero web changes. **MH-1a** register→login→me→logout flow green in tests/employers/test_auth.py; logout revokes (subsequent me → 401); concurrent sessions survive single logout. **MH-1b** REAL scrypt in every auth test (no fake-hash path; ≈2 hashes/test within the R-55 budget); params are module constants pinned by test; hash bytes never asserted. **MH-1c** employer_sessions stores ONLY sha256(token) (store-pinned); lazy 7-day TTL pinned via injected now (+6d present, +8d absent — no sleeps). **MH-1d** both throttle windows green in tests/employers/test_throttle.py — 5 failures/15 min → 429 with wait detail, success clears the email window, global cap → 429, rejected attempts consume nothing; reset fixtures isolate module state. **MH-1e** CORS preflight with `Access-Control-Request-Headers: Authorization` allowed — pinned in tests/api/test_cors.py (landed with the routes, not after). **MH-1f** a 422 on register never echoes the submitted password or email (pii_fields pin); mypy + ruff + full fast pytest green, zero new mypy overrides; no learner gate composed on any employer route (D-097).
- **P2 (REQ-10-002):** claim → pending → admin approve → account.employer_id set, all pinned. **MH-2a** claim→approve→ownership set + audit row green in tests/employers/test_claims_admin.py. **MH-2b** evidence dict `{account_email_domain, entity_website_domain, match}` computed at claim time, stored as provenance, pinned in tests/employers/test_claims.py; displayed nowhere as verification (match=false corpus-wide per R-65). **MH-2c** one PENDING claim per entity + one APPROVED owner per entity + rejection frees the entity — all three pinned in tests/employers/test_claims_store.py. **MH-2d** all five operator routes (accounts/claims/approve/reject/audit) fail-closed 403 without a valid admin token.
- **P3 (REQ-10-003):** the dashboard reads real data through the authenticated surface. **MH-3a** pins.test.ts employer.ts block asserts every field both schemas (ai_service/employers/base.py ↔ packages/types/employer.ts, D-093; PostingSummary reused from marketplace.ts, never duplicated). **MH-3b** 401 → SessionRequiredError → honest logged-out state with login CTA, never a retry loop (web test-pinned). **MH-3c** postings + overview pages grep-clean of removed mock imports (`jobs`, `candidates`, `employers`, inline METRICS/PIPELINE constants); own-postings lifecycle + real active count + per-posting applicant counts render from GET /v1/employers/postings; unclaimed → claim-CTA state visible; claim-pending state visible. **MH-3d** talent search + candidate profile pages keep their mock imports + mock labels (D-092 honest out-of-scope). **MH-3e** /employer/login + /employer/register render with honest 401/409/429/422 states; `pnpm --filter web build` (static export) green.
- **P4 (REQ-10-004):** one shared gate, two principals, no drift. **MH-4a** the 402 payload is byte-identical between admin and employer gates on `{reason, posts_used: 0, posts_limit: 0}` — only upgrade_cta carries the principal's CTA — pinned by the payload-parity test; **tests/billing/test_entitlement_enforcement.py green UNCHANGED**. **MH-4b** gate-before-insert: a 402 consumes nothing and creates nothing (posts_used unchanged, no row) — pinned. **MH-4c** employer create consumes from the employer's OWN package (posts_used+1 pinned); both 402 arms (no_active_package, posting_limit_reached) proven via direct store grants; unlimited bypass holds. **MH-4d** cross-entity access → 403; own-drafts-only edit (published → 409); publish/close lifecycle green in tests/employers/test_postings_write.py; admin posting routes unchanged (existing marketplace_admin tests green unmodified).
- **P5 (REQ-10-005):** the B2B loop closes machine-checked. **MH-5a** employer checkout E2E through the REAL webhook path green in tests/employers/test_checkout_e2e.py: register → claim → approve → checkout → own mock-complete → purchase exists with posts available (G-303 exactly-once holds — duplicate complete never double-grants). **MH-5b** cross-session mock-complete → 403; the route 404s under provider=stripe (mock-only mount) — both pinned (the G-301 class closed). **MH-5c** unclaimed checkout → 403 + claim-CTA; entitlement view renders honest none/active/expired states. **MH-5d** pricing CTAs route to /employer/login (contact-path copy gone from purchasable tiers); the enterprise "Contact Sales" pin retained (pins.test.ts:250). **MH-5e** admin network overview renders the accounts list, claim queue with advisory evidence + approve/reject actions, and the audit view; 403 → honest locked state with token prompt (web tests).
- **P6 (ship):** **MH-6a** multi-persona review clean (P0s fixed in-phase); the D-091 no-drift + wire-pin verdicts recorded. **MH-6b** security final review clean (no credentials in logs/releases; audit trail complete). **MH-6c** reconstruction + discipline checks green; v0.9.6 tagged, merged to main, release carries the full asset matrix + registry image via the existing lane; REQ-10-001..005 marked complete; branches deleted; checkpoint cleared.
## Milestone Must-Haves
- REQ-9-001..005 all complete; the mock payment loop is the machine-checked E2E proof (checkout → webhook → entitlement → consumption → renewal/cancel).
- The stripe provider is byte-contract-proven without ever leaving MockTransport.
- Zero secrets in code/tests/examples; AI_WEBHOOK_SECRET unset → webhooks fail closed.
- Suite counts: pytest + web + cli all green, zero warnings (v0.7 regime holds).
- Docs: .env.example billing section; deploy/README.md unaffected (billing needs no admin-box changes — record this explicitly); REQUIREMENTS/ROADMAP updated.
- REQ-10-001..005 all complete per their REQUIREMENTS.md rows.
- The full B2B loop is the machine-checked E2E proof: register → login → claim → admin approve → create draft → buy package (own mock checkout → REAL webhook) → publish → posting live — every step pinned in tests/employers/.
- Zero new dependencies (R-66), zero workspace changes, zero mypy overrides; scrypt/secrets/hmac/sha256/urllib stdlib-only end-to-end.
- The v0.9 wire contracts unchanged: UpgradeRequiredError byte-stable, existing billing/marketplace_admin entitlement tests green unmodified.
- Suite counts: pytest fast lane + web + cli all green, zero warnings (the v0.7 regime holds).
- Docs: .env.example throttle knobs; REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — record explicitly); the mock-first doctrine intact (no external auth provider, tests never call a cloud).
## Risks
1. **Raw-body discipline** (risk-1): any body pre-parse breaks HMAC — pinned by MH-3a + route-order test.
2. **Dual-charge** (R-47): pinned by MH-3d.
3. **Out-of-order events** (R-45): transitions tolerate duplicates; InvalidTransition recorded not raised for already-reflected state — pinned in webhook tests.
4. **Test clock**: now-injection in verify + store (no sleeps — deterministic).
5. **Backward compat at P4**: existing marketplace admin tests create postings for seeded employers — seed catalog + grant manual unlimited to test employers OR mark route change explicit in fixtures (decided: tests grant unlimited to legacy seeded employers — honest, explicit).
6. **Pins churn**: pins.test.ts grows — keep the G-108 discipline (regression/boundary pins only, no local-grep copy pins).
1. **scrypt CPU on concurrent auth** (R-55, ~50 ms/hash single-threaded; a login burst stacks) — bounded by both throttle windows; budget recorded in the passwords.py docstring; acceptable at pilot.
2. **CORS Authorization omission kills every cross-origin employer fetch** (E87 class, R-60/open-risk-2) — the main.py edit + the test_cors.py pin land in P1 Task 1-2-03, the same wave as the routes, never after.
3. **422 password echo** (R-56/open-risk-3) — pii_fields fix + pin land in P1 with the register route; auditor re-verifies at the P1 review.
4. **sessionStorage token exposure** (D-092 tradeoff, open-risk-4) — tab-scoped by design, exposure documented in surface copy; tokens stored hashed server-side (DB leak yields nothing); logout revokes the row.
5. **Employer mock-complete cross-session completion** (G-301 hole class, open-risk-5) — own-session check (403 cross-session) + mock-only mount + session gate, all pinned in P5.
6. **example.com seed websites make claim evidence match=false corpus-wide** (R-65/open-risk-6) — the queue copy presents the signal as advisory; the operator decision activates; auto-approval stays deferred (A-304).
7. **D-091 extraction drift** (the 402 dict's hardcoded zeros are easy to "improve") — the payload-parity test + unchanged v0.9 entitlement suite are the no-drift proof; lead-developer reviews at P4.
8. **Throttle module-state leaks across tests** (R-63) — both limiters get reset fixtures from day one (the _reset_apply_rate_window pattern).
## User-Facing Surface
1. **Pricing page** — real catalog data at runtime (packages + plans with cents-true prices), honest loading/error states, contact-path CTAs (operator-mediated checkout note).
2. **Admin billing surface** — per-employer entitlement panel (package, posts used/limit, expiry, subscription badge) in the moderation view; operator checkout trigger.
3. **Public API** — GET /v1/billing/catalog (advertises the catalog); POST /v1/billing/webhook (provider-facing).
4. **Operator API** — /v1/billing/admin/* (checkout, entitlements, lifecycle, grants, event log) behind the admin token.
5. **Release notes** — v0.8.x line ships the billing surface through the existing automated lane (no admin-box changes required).
1. **/employer/login + /employer/register** — greenfield auth pages with honest 401/409/429/422 states; register → logged-in dashboard; sessions tab-scoped via `nc-employer-session`.
2. **Employer dashboard — postings page** — the employer's OWN postings lifecycle (draft → published → closed) with real applicant counts, create/edit/publish/close actions, honest 402 upgrade CTA + 403 claim prompt.
3. **Employer dashboard — overview** — real active-postings count + per-posting applicant counts + claimed-entity profile; talent-match/placement metrics honestly mock-labeled; claim-pending state visible.
4. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled (no candidate backend exists — D-092).
5. **Pricing page** — CTAs convert to the employer-login path (self-serve checkout is real); enterprise tier keeps Contact Sales.
6. **Admin moderation page — network overview** — employer accounts list, claim queue with advisory evidence + approve/reject, audit view; 403 → honest locked state.
7. **API surfaces** — /v1/employers/* (session-gated: auth, claims, postings, billing) + /v1/employers/admin/* (operator-gated) + the existing /v1/billing/* reused verbatim.
8. **Release notes** — the v0.9.x line ships the employer network through the existing automated lane (no admin-box changes required).
## Happy Path (end-to-end, written BEFORE execute)
1. **Operator buys for an employer (mock pilot):** admin token → POST /v1/billing/admin/checkout {item: bundle, employer: E1} → session created → mock complete → webhook (signed, deduped) → purchase row: 10 posts, $3.99 snapshot → E1's admin panel shows 10/10 posts available.
2. **Employer posts jobs:** create_posting E1 → 201, posts_used 1..N; at 10/10 the 11th → 402 with upgrade_cta → operator buys enterprise grant → unlimited.
3. **Subscription month rolls:** pro plan renews → invoice.paid webhook → expires_at extended exactly once (double-delivery safe) → talent-access stays active.
4. **Payment fails:** card declines → invoice.payment_failed → past_due → 7-day grace → still unpaid → expired (honest downgrade) — OR recovery payment inside grace → active again.
5. **Stripe switch (env-only):** AI_PAYMENT_PROVIDER=stripe + keys → same routes, real hosted checkout, real webhooks — zero code changes.
6. **Visitor checks pricing:** /marketplace/pricing loads → catalog fetched → prices + tiers match the DB exactly.
1. **Employer registers:** /employer/register → account created (email unverified, labeled as such) → logged in → dashboard shell with the claim prompt.
2. **Employer claims their entity:** picks their EmployerRow from the marketplace corpus → claim pending, evidence recorded → dashboard shows the honest claim-pending state.
3. **Operator approves:** admin moderation page → claim queue shows the evidence + domain-match signal (advisory) → approve → account bound to the entity (one owner) → audit row written.
4. **Employer tries to post without a package:** create draft → 402 with upgrade CTA (reason: no_active_package) → nothing consumed, nothing created.
5. **Employer buys a package (pilot mock):** dashboard billing surface → own checkout over the catalog → in-app mock-complete (honestly labeled) → REAL webhook path → purchase granted → posts available in the entitlement view.
6. **Employer publishes:** create draft (posts_used+1) → publish → posting live on the public job board; applicant counts accrue on the overview; the 11th post on a 10-pack → 402 upgrade CTA again.
7. **Operator oversight throughout:** the network overview shows the account, claimed entity, and entitlement state; the audit trail answers every claim/grant/revocation decision.
## UX Acceptance Criteria
1. No dishonest buttons: every CTA states the real pilot path (operator-mediated); no fake "buy now" without a real flow behind it.
2. Prices are always cents-true and consistent: pricing page = catalog API = purchase snapshots; no drift.
3. Failures are loud and actionable: 402 payloads carry upgrade_cta; webhook failures fail closed (401) rather than processing unsigned; past_due is visible in the admin panel before downgrade.
4. The admin never needs to guess: entitlement panel answers "can this employer post?" in one glance; the event log answers "what happened to this subscription?" with full provenance.
5. Existing surfaces unchanged: job board, defense, sandboxes, deploy — billing is additive; any regression there is a P0.
---
# v0.8 PLAN (Ship Anywhere — prior milestone, retained below)
## Overview
**Milestone:** v0.8 — Ship Anywhere (Feature): nextcraft installable on any Debian-based Linux server via every mainstream channel — docker image from the Gitea registry, .deb packages, one-liner binary (x64 + arm64) — with release consistency enforced by pre-commit hooks + Gitea Actions pipelines, and the CLI carrying the full suite for zero → running onboarding. Admin guides document all of it.
**Tag line:** phase tags advance the v0.7.x line (P0 → v0.7.0 … P6 → v0.7.6 = milestone release), continuing from the v0.6.6 milestone release
**Branch:** milestone/v0.8-ship-anywhere
**Phase list (requirement mapping):**
| Phase | Requirement | Focus |
|-------|-------------|-------|
| P1 | REQ-8-001 | Release-consistency rails: runner fleet on CT 113, push/tag workflows, pre-commit, hello-world verification |
| P2 | REQ-8-002 | Docker image: multi-stage build, unprivileged runtime + honest sandbox degrade, registry push on every release |
| P3 | REQ-8-003 | Debian-native: arm64 cross-build, .deb packages (x64+arm64), install.sh arch-resolve, release assets extended |
| P4 | REQ-8-004 | CLI onboarding: doctor channel awareness, `nextcraft guide`, in-container refusals, per-channel E2E |
| P5 | REQ-8-005 | Admin guides: the full deploy runbook (three channels × install/upgrade), README quickstart |
| P6 | — | Final review + audit + milestone ship (v0.7.6 = milestone release) |
**Wave order (binding): rails before artifacts before onboarding before guides.** P1 builds the rails everything else ships through — P2/P3 extend the release workflow P1 creates; P4 onboarding needs all artifact channels to exist; P5 guides document the final shape. Within P1, the runner fleet gates the workflow verification (workflows are inert config until a runner registers). P2 precedes P3 for founder priority (docker is the headline channel), not technical force — both consume P1's release workflow as their extension point (G-202).
**Research decisions R-1..R-32 (RESEARCH.md) and clarify decisions D-063..D-072 (PROJECT.md) are binding design contracts.** This plan operationalizes them; it does not re-litigate them.
**Environment facts (probe-verified):** Gitea 1.26.1 at git.coreci.dev; OCI registry `/v2/` live (401 unauth); Actions enabled, zero runs to date; CT 113 gitea-runner LXC (10.0.0.113, 4c/4GB/32GB, root SSH, founder-provided credentials — never committed); registration token founder-provided in chat, reset after fleet green; latest release v0.6.6 (id 909) carries nextcraft-linux-x64 + .sha256; runner image is `docker.io/gitea/runner:3` (act_runner renamed, R-1); jobs run as siblings via host docker.sock; v0.7 toolchain (eslint flat config, ruff + RUF100, mypy relaxed-first, pytest markers fast/real_server/userns with `ai:test:fast`, c8, turbo pipeline) is the CI surface.
**Secrets policy (binding, v1.8 lesson):** tokens never in shell env, never committed, never echoed. Registration token = compose env at bring-up only (R-6). REGISTRY_TOKEN = Gitea Actions secret (self-serve `PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` attempt with the `.ciagent/.env.secrets` GITEA_TOKEN first; founder UI fallback — R-13). The automatic job `GITEA_TOKEN` cannot publish packages (R-13, upstream-confirmed). CT 113 root credentials: use once at P1 bring-up, install repo-owned access thereafter, founder rotates the password.
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to the four channels + guides.
---
## Phase 1: Release-Consistency Rails (REQ-8-001)
**Goal:** Three org-level runner containers green on CT 113; push-lane workflows (node/python lanes per D-063) and a tag-triggered release workflow live; pre-commit hooks enforce local hygiene; the fleet proven by hello-world dispatch across all three labels.
### Wave 1-1: Runner fleet bring-up on CT 113 (devops-engineer)
- **Task 1-1-01** (devops-engineer): CT 113 bring-up probes — SSH to root@10.0.0.113 (founder creds, use once), verify docker daemon (`docker info`), LXC nesting health (cgroup/ns errors), egress (`curl -fsSL https://git.coreci.dev/api/v1/version` → 200; docker.io reachability for `gitea/runner:3` + job images; github.com reachability for actions/checkout per R-8 — record verdict, it selects the checkout strategy). Install an ed25519 deploy key for subsequent phases; founder rotates root password after this task — **rotation is a Wave 1-1 completion condition (G-206): if unconfirmed by Wave 1-2, commit `escalation: ct_root_password_still_live` and carry it in the ship notes — never silently.**
- **Task 1-1-01b** (devops-engineer): **Fleet-as-code (G-207)** — the compose file, per-runner config.yaml snippets, and the prune cron land in-repo under `deploy/ci/` (compose with `${REGISTRATION_TOKEN}` env interpolation, configs with `${CT_IP}` documented as bring-up variables); CT 113 deployment = copy + `docker compose up -d`. The runbook's fleet recipe references these committed files, never box memory.
- **Task 1-1-02** (devops-engineer): Fleet deployment per R-1..R-5 — `/srv/nc-ci/docker-compose.yaml` with three services (nc-runner-node / nc-runner-python / nc-runner-release), image `docker.io/gitea/runner:3`, per-runner `./data-runner-*` volumes (never shared — lock refusal), host `/var/run/docker.sock` mount, `GITEA_INSTANCE_URL=https://git.coreci.dev`, `GITEA_RUNNER_NAME`, `GITEA_RUNNER_LABELS` per the R-3 hybrid map (node → node:24-bookworm; python → python:3.11-bookworm; release → node:24-bookworm; ubuntu-latest → docker.gitea.com/runner-images:ubuntu-latest, carried by runner-node), registration token via compose env (from founder chat, never a file). Per-runner config.yaml with `capacity: 1`, `cache.enabled/host: 10.0.0.113/port` published per runner (R-5 — else actions/cache ETIMEDOUTs), `health_check.min_free_disk_space_mb: 4096`. systemd unit for the compose stack (`restart: always`, reboot-persistent — house pattern).
- **Task 1-1-03** (devops-engineer): Fleet verification — all three runners visible + idle at org level (`/org/coreci/settings/actions/runners`); registration token reset by founder after this task (ask; do not hold the pipeline — the token's job is done once three `.runner` files exist); host-side cache-hygiene cron (daily `docker system prune -af --filter until=168h` + `docker volume prune -f`).
### Wave 1-2: Workflows + secrets + hello-world (devops-engineer)
- **Task 1-2-01** (devops-engineer): `.gitea/workflows/ci.yaml` — `on: push` (all branches), two jobs: node lane (`runs-on: node`: corepack pnpm 12.3.4 — probe corepack-on-node24, `npm i -g pnpm@12.3.4` fallback per R-11 — `pnpm install --frozen-lockfile`, lint + typecheck via `pnpm --filter` over the 5 TS workspaces (never wholesale `turbo lint` per R-8), `pnpm web:test`, `pnpm cli:test` with E2E env-gated out per D-063) and python lane (`runs-on: python`: bootstrap.sh → lint.sh → typecheck.sh → `FAST=1 test.sh`). checkout via `actions/checkout@v4` if Task 1-1-01 found github.com egress; else raw `git fetch` steps with the job token (R-8). pnpm store + pip cache via `actions/cache@v4` (R-12; volume-mount fallback if cache server unreachable).
- **Task 1-2-02** (devops-engineer): `.gitea/workflows/release.yaml` — `on: push: tags: ['v*']`, `runs-on: release`: build the x64 SEA binary stamped `${{ gitea.ref_name }}` (`pnpm --filter @nextcraft/cli build:binary $TAG`), sha256 sidecar, attach both to the tag's release via the REST API (`scripts/release-assets.sh` mechanics per R-14 — call the API directly in the workflow step; the script itself stays untouched as the manual fallback with its .env-only resolution, per D-064/R-14). **Within-phase proof (G-203):** at verify time push a temporary `v0.7.1-rc1`-style tag to the phase-branch head, observe the workflow build + attach green (draft/pre-release or immediately-deleted release), then delete the tag — the phase ship tag remains the real artifact.
- **Task 1-2-03** (devops-engineer): `.gitea/workflows/hello.yaml` — `workflow_dispatch` one-step echo job × 3 (`runs-on: node|python|ubuntu-latest`) — the REQ-8-001 fleet proof. Dispatch all three, observe green in the Actions tab.
- **Task 1-2-04** (devops-engineer): REGISTRY_TOKEN setup per G-205 — the secret is a **founder-minted PAT with package-write scope** (write:package + write:repository); first attempt the self-serve PUT (`PUT /repos/coreci/nextcraft/actions/secrets/REGISTRY_TOKEN` with the `.ciagent/.env.secrets` token, R-13); if the token's scope cannot mint it, escalate to the founder with the exact scope needed — a founder question, never a silent wait. P2's registry push blocks on this secret existing; P1 does not.
- **Task 1-2-05** (devops-engineer): `.pre-commit-config.yaml` at repo root (D-070): directory-scoped hooks — ruff (ai-service globs), eslint flat-config run (web/cli/packages globs), tsc `--noEmit` per TS workspace, basic hygiene (trailing whitespace, merge conflicts, large files). Local-only, no network, fail-loud on missing toolchain (per D-070 — CI backstops). Document the opt-in install AND the no-toolbox skip path in the README dev section (G-209 — a contributor without ruff gets a loud pointer, not a broken commit flow).
### Wave 1-3: P1 verification (lead-developer + security-auditor)
- **Task 1-3-01** (security-auditor): Secrets audit of P1 — no token/credential in any committed file (compose, workflows, pre-commit, docs); registration token lifecycle honored (brought up via env, reset requested); CT root password absent from repo + rotated by founder; workflow env scrubbed (no `GITEA_TOKEN` echo steps).
- **Task 1-2-05 → verification hook** (lead-developer): push-lane proof — a deliberate commit touching both lanes lands green runs in the Actions tab; hello-world dispatch green × 3 labels; pre-commit blocks a deliberately broken lint file and passes a clean tree (both directions proven locally).
**Verification hooks P1:** ci.yaml green on a fresh push (both lanes); hello.yaml green × 3 labels; release.yaml exercised at P1 ship (the v0.7.1 tag's release carries binary + checksum, built by the workflow — not by the local ship path); pre-commit both-direction proof; secrets audit clean; runners idle-registered at org level.
---
## Phase 2: Docker Image Channel (REQ-8-002)
**Goal:** The full stack as one unprivileged container — deps pre-loaded, single-port topology preserved, honest sandbox degrade, pushed to the Gitea registry on every phase release.
### Wave 2-1: Image + degrade (devops-engineer + sandbox-engineer)
- **Task 2-1-01** (devops-engineer): Multi-stage Dockerfile per R-16..R-19 — web-export stage (node:24-bookworm, corepack pnpm, `NEXT_PUBLIC_AI_SERVICE_URL=self pnpm build`, outputs `apps/web/out`), venv stage (python:3.11-bookworm, bootstrap.sh mechanics), runtime stage (python:3.11-slim-bookworm, non-root `nextcraft` user, uvicorn :8420, `AI_WEB_STATIC_DIR` → export, `NC_CONTAINER=1` marker, `EXPOSE 8420`, `HEALTHCHECK` GET /health, state volume `/home/nextcraft/.nextcraft`). CLI embedded per D-066 (`doctor`/`verify`/`guide` usable in-container; image tag stamped `NEXTCRAFT_VERSION_STAMP`).
- **Task 2-1-02** (sandbox-engineer): Honest degrade per D-068/R-27..R-29 — `ai_service/sandbox/capability.py` promoting `tests/helpers/userns.py::userns_available()` verbatim (real probe, truth source: attempt userns + report), boot notice at startup when degraded (main.py G-11 loud-warning pattern), 503 arm beside PoolFullError in sandboxes.py (env creation rejected with remediation payload; web surfaces already render honest errors — additive only). **Remediation text is a single tested constant (G-208):** names the unprivileged default, the seccomp constraint, the documented privileged/seccomp-override variant (exact docker run flags), and the host-side alternative — the boot notice and the 503 payload carry the same constant. Tests: degraded-mode unit tests (probe mocked false → 503 + notice asserted); real userns path unchanged.
- **Task 2-1-03** (cli-engineer): In-container CLI refusals per D-066/R-30 — `bootstrap`/`deploy`/`dev` detect `NC_CONTAINER` (or `.dockerenv` + marker) and refuse with honest exit 1 + remediation text (image is immutable; upgrade = docker pull per D-071); `doctor` gains container-mode branch (skips repo-root checks, verifies mount + /health); `verify` container semantics (probe the live listener + export presence).
### Wave 2-2: Registry push + release integration (devops-engineer)
- **Task 2-2-01** (devops-engineer): Release workflow registry step per R-20/D-065 — `docker build` on the tag ref, `docker login git.coreci.dev` with `secrets.REGISTRY_TOKEN`, push `git.coreci.dev/coreci/nextcraft:$TAG` + `:latest`. Blocks on Task 1-2-04's secret existing (escalation if still pending: ship proceeds, push step marked `continue-on-error: false` + release notes note the missing image).
- **Task 2-2-02** (devops-engineer): `docker run` smoke gate in the workflow — five pinned assertions (G-204): container starts → `/health` 200 within timeout → `GET /` is content-type text/html AND contains a known page marker (not merely non-JSON-404) → degrade boot notice present in container logs → fabric env-create returns 503 with the remediation payload → clean stop. A broken image = failed release job.
- **Task 2-2-03** (security-auditor): Image audit — non-root confirmed, no secrets in layers (`.env*`/`.ciagent/` excluded via `.dockerignore`), healthcheck present, minimal runtime (no build toolchain in the final layer).
**Verification hooks P2:** image builds locally + on the runner; pushed to the registry under the phase tag + latest; smoke gate green (HTML at /, degraded fabric reported honestly); degrade tests green in the fast pytest lane; in-container doctor/verify/refusals green (CLI tests); `.dockerignore` excludes secrets; all existing suites stay green (no regressions from capability.py promotion — the real userns suites unchanged).
---
## Phase 3: Debian-Native Channel (REQ-8-003)
**Goal:** arm64 binaries + .deb packages for x64 + arm64, attached to every release; install.sh resolves arch; every channel ends at doctor/verify green.
### Wave 3-1: arm64 cross-build (cli-engineer)
- **Task 3-1-01** (cli-engineer): `build-binary.mjs` arch parameter per R-25 — `--arch x64|arm64`; the ONLY arch-specific step (copy of `process.execPath`) replaced by the official arm64 node tarball download (probe-verified 200/30MB) for arm64; esbuild bundle + postject injection unchanged (arch-agnostic file surgery); output `nextcraft-linux-arm64` + `.sha256`. **Decided fallback ladder (G-201):** probe QEMU-free cross-arch SEA on the runner first; if the blob fails under emulation, fall back to binfmt+qemu for FULL arm64 builds; if the runner cannot install binfmt, ship v0.8 x64-only (arm64 deferral recorded in ship notes + REQUIREMENTS) — never leave the release lane red or blocked.
- **Task 3-1-02** (cli-engineer): Binary contract tests — arm64 binary `--version` reports the stamped tag (under emulation in CI); x64 path byte-identical to the v0.4 pipeline (checksum + version gates unchanged).
### Wave 3-2: deb packages + installer + release extension (cli-engineer + devops-engineer)
- **Task 3-2-01** (cli-engineer): `scripts/build-deb.sh` per R-22/R-23 — `dpkg-deb --root-owner-group -Zxz`, package `nextcraft` Version = tag minus `v`, empty Depends + `Recommends: nodejs, python3, git, util-linux`, contents: `/usr/bin/nextcraft` (arch-matched binary) + `/usr/share/doc/nextcraft/` (README + guide); postinst = advisory doctor (`|| echo` → always exit 0, never fails dpkg per D-067); outputs `nextcraft_<ver>_<arch>.deb` + sidecars.
- **Task 3-2-02** (cli-engineer): `install.sh` arch-resolve per R-24 — `uname -m` x86_64 → x64 asset, aarch64/arm64 → arm64 asset; checksum + version integrity gates byte-identical for both; degradation messaging unchanged.
- **Task 3-2-03** (devops-engineer): Release workflow extension — arm64 binary build + both debs join the release-asset attach step (x64 binary, arm64 binary, 2× sha256, 2× deb — full asset matrix per D-064); smoke: deb installs in a fresh debian container, `nextcraft --version` + advisory doctor run. **Proof via the G-203 temporary-rc-tag mechanism.**
- **Task 3-2-04** (security-auditor): Asset audit — all 6 release assets checksum-verified, version stamps match the tag, no secrets in debs.
**Verification hooks P3:** arm64 binary + 2 debs build in CI (release lane green on the phase tag); install.sh fetches the right asset per arch (fixture-server test extended, tamper-rejection unchanged); deb installs → binary on PATH → advisory doctor runs; x64 pipeline byte-identical (no regression); asset matrix complete on the v0.7.3 release.
---
## Phase 4: CLI Onboarding (REQ-8-004)
**Goal:** `nextcraft` alone takes an admin zero → running — channel-aware doctor, `guide`, honest refusals, per-channel E2E.
### Wave 4-1: Channel awareness + guide (cli-engineer)
- **Task 4-1-01** (cli-engineer): Doctor channel awareness per R-30 — container-mode branch (from P2), deb-install detection (`dpkg -s nextcraft` or binary-at-/usr/bin heuristics; report channel + its upgrade semantics per D-071), binary-install default. Each channel's doctor outputs the right next-step line (deploy --service vs docker pull + recreate).
- **Task 4-1-02** (cli-engineer): `nextcraft guide` per D-072 — offline-first embedded man-page text (no runtime fetches): sections per channel (first install, upgrade, troubleshooting pointers), forge URLs as pointers. `guide [topic]` optional arg (guide deploy / guide docker / guide deb / guide troubleshooting).
- **Task 4-1-03** (cli-engineer): CLI tests for the new surface — dispatch tests (guide routing, refusals, doctor branches per detected channel via env/fixtures); exit-code contract unchanged (0 ok / 1 failure / 2 usage).
### Wave 4-2: Per-channel E2E (cli-engineer + devops-engineer)
- **Task 4-2-01** (cli-engineer): Docker-channel E2E (env-gated like the v0.4 E2E): pull the released image, run, in-container doctor/verify green, refusals honest, teardown. Wired as `cli:test:e2e:docker`, excluded from default lanes.
- **Task 4-2-02** (cli-engineer): deb-channel E2E (env-gated): fresh debian container, install the released deb, advisory doctor, `nextcraft guide`, teardown. `cli:test:e2e:deb`.
- **Task 4-2-03** (lead-developer): The binary channel E2E already exists (fresh-clone, v0.4) — verify it still passes with the arch-resolve installer; wire the three E2E names into the docs so admins can reproduce them.
**Verification hooks P4:** all CLI suites green at current counts; guide renders offline (no network mock asserted); doctor reports the right channel in all three modes (fixture tests); docker + deb E2E green when env-gated on; existing binary E2E green; no regression in the 61-test CLI baseline surface (net new tests allowed).
---
## Phase 5: Admin Guides (REQ-8-005)
**Goal:** The admin runbook — every channel's first-install + upgrade recipe, evidence-backed, one entry point.
### Wave 5-1: The runbook (lead-developer + cli-engineer)
- **Task 5-1-01** (lead-developer): `deploy/README.md` → the full admin runbook per R-31/R-32 — structure: pick-your-channel matrix → docker channel (docker run, volumes, privileged variant for full in-container sandboxes, upgrade = pull + recreate per D-071) → deb channel (dpkg -i, advisory doctor, deploy --service integration) → binary channel (install.sh one-liner, existing recipe preserved) → upgrade matrix table → HAProxy/systemd recipes (existing content preserved) → troubleshooting tables (runner/registry/docker-degrade/deb — extended from the CLI README's) → evidence appendix (which test or live probe backs each recipe).
- **Task 5-1-02** (cli-engineer): `nextcraft guide` content parity — the runbook's channel sections are the canonical text, extracted to a single shared source (G-210); the guide renders from that source; divergence = parity test failure (never doc drift).
- **Task 5-1-03** (lead-developer): README quickstart updated — three channels listed with one command each; the v0.8 milestone line added to the history table.
### Wave 5-2: Guide verification (lead-developer)
- **Task 5-2-01** (lead-developer): Every runbook recipe evidence-checked — each recipe carries a pointer to its backing test (E2E name, workflow job, or live-box probe from prior phases); the parity test proves guide ↔ runbook agreement; doc lint (markdown structure) if cheap.
**Verification hooks P5:** runbook complete (all channels, upgrade matrix, troubleshooting, evidence appendix); parity test green; README quickstart accurate; no unevidenced recipe claims (each has a pointer); existing deploy recipes (systemd/HAProxy) preserved verbatim-accurate.
---
## Phase 6: Final Review + Ship (milestone release, v0.7.x line)
- **Task 6-1-01** (lead-developer → ci-review personas): multi-persona review of the full milestone branch diff (all phases — workflows correctness, Dockerfile hardening, deb packaging, CLI surface, docs accuracy); security-auditor confirms secrets policy end-to-end (no token in any layer/asset/workflow), image non-root, deb postinst advisory-only.
- **Task 6-2-01** (ci-audit): reconstruction test (git log ↔ .ciagent/ files), file/branch/commit discipline, tag hygiene; critical fixes in-phase.
- **Task 6-2-02** (lead-developer → ci-ship): merge milestone → main; tag the milestone release (final v0.7.x phase tag = v0.7.6); Gitea release with full summary + the complete asset matrix (x64/arm64 binaries + checksums + 2 debs) + registry image tags; delete milestone branches; mark REQ-8-001..005 complete; clear checkpoint.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
### Phase 1
- **MH-1a**: Three runners visible + idle at org level (`/org/coreci/settings/actions/runners`) — named nc-runner-node/python/release, hybrid labels declared per the R-3 map; registration token reset requested from founder after green fleet.
- **MH-1b**: `ci.yaml` green on a fresh push — node lane (lint+typecheck via per-workspace filters, web:test, cli:test) + python lane (bootstrap/lint/typecheck/FAST test) both observed in the Actions tab.
- **MH-1c**: `hello.yaml` dispatch green × 3 labels (node/python/ubuntu-latest) — the fleet proof.
- **MH-1d**: `release.yaml` exercised via the G-203 temporary rc tag on the phase branch — workflow observed building binary + checksum and attaching them green; the phase ship release (v0.7.1) carries the same machine-made assets.
- **MH-1e**: pre-commit proven both directions — blocks a deliberately broken lint file, passes a clean tree; config is local-only (no network hooks); the no-toolbox skip path is documented in README dev (G-209).
- **MH-1f**: Secrets audit clean — grep-verifiable: no token/credential in any committed file; CT root password used once, founder rotation requested as a Wave 1-1 completion condition and confirmed or escalated (G-206); workflows carry no shell-env token echoes.
### Phase 2
- **MH-2a**: Image builds (local + release lane) and pushes to `git.coreci.dev/coreci/nextcraft` under the phase tag + `latest`; `/v2/` manifest probe-verified after push.
- **MH-2b**: Smoke gate green with the five pinned assertions (G-204) — container start → /health 200 → `GET /` HTML with known marker → degrade notice in logs → 503 with remediation on env-create → clean stop; a broken image fails the release job.
- **MH-2c**: Degrade is honest — capability probe is a real userns attempt (not an env-var guess); degraded boot notice logged once with remediation text; env-create rejected 503 with remediation payload; real userns path unchanged (userns suites green locally).
- **MH-2d**: In-container CLI semantics — doctor/verify/guide work; bootstrap/deploy/dev refuse with remediation (exit 1); all pinned by tests.
- **MH-2e**: Image hygiene — non-root runtime, `.dockerignore` excludes `.env*` + `.ciagent/`, no build toolchain in the final layer (auditor-verified).
### Phase 3
- **MH-3a**: arm64 binary builds per the G-201 ladder — QEMU-free cross-arch SEA if the probe passes; binfmt+qemu full build if not; x64-only deferral (recorded) if binfmt is impossible — the ladder's outcome is recorded either way, `--version` reports the stamped tag under emulation, sha256 sidecar matches.
- **MH-3b**: Both debs build + install in fresh debian containers — `nextcraft --version` + advisory doctor run; postinst never fails dpkg (proven with missing-Recommends environment).
- **MH-3c**: install.sh arch-resolve — x86_64 → x64 asset, aarch64 → arm64 asset, checksum + version gates byte-identical; fixture-server tests extended to both arches.
- **MH-3d**: v0.7.3 release carries the complete asset matrix: 2 binaries + 2 checksums + 2 debs (6 assets), all verified — or the recorded G-201 deferral outcome (x64-only) with the arm64 rows explicitly marked deferred, never silently missing.
### Phase 4
- **MH-4a**: Doctor reports the detected channel (container/deb/binary) with the channel-correct next-step line, in fixture tests covering all three.
- **MH-4b**: `nextcraft guide` renders offline (no network calls — asserted), topics route (`guide docker|deb|troubleshooting|deploy`), exit-code contract intact.
- **MH-4c**: Refusals honest — in-container bootstrap/deploy/dev exit 1 with remediation text (pinned by tests).
- **MH-4d**: Both new E2Es (docker, deb) exist env-gated, green when run; the binary-channel E2E still green; guide ↔ runbook parity test green.
### Phase 5
- **MH-5a**: Runbook covers all three channels (first-install + upgrade each), the upgrade matrix, HAProxy/systemd recipes, and the four troubleshooting tables — every recipe carries an evidence pointer (test name / workflow job / live probe).
- **MH-5b**: Parity test green — `nextcraft guide` content and the runbook agree section-by-section.
- **MH-5c**: README quickstart lists all three channels accurately; milestone history updated.
### Phase 6
- **MH-6a**: Multi-persona review of the full diff clean (P0s fixed in-phase); secrets policy confirmed end-to-end by security-auditor.
- **MH-6b**: ci-audit reconstruction + discipline checks green.
- **MH-6c**: Milestone shipped — v0.7.6 tagged, merged to main, release carries the full asset matrix + image tags, REQ-8-001..005 complete, branches deleted, checkpoint cleared.
## Milestone Must-Haves
- **MH-M1**: All five requirements complete per their REQUIREMENTS.md rows (REQ-8-001..005).
- **MH-M2**: A push to any branch runs lint + typecheck + fast tests on the runner fleet, green (the release-consistency rail the founder asked for).
- **MH-M3**: Every phase release from v0.7.1 onward is machine-made the same way: tag push → workflow builds → binary + checksum (+ image from P2, + arm64/deb from P3) attached + registry pushed.
- **MH-M4**: Each channel takes a fresh Debian server to a running verified stack: docker run → /health + HTML; dpkg -i → doctor → deploy --service; install.sh → deploy --service (proven by E2E/workflow smoke or live-box evidence).
- **MH-M5**: The admin runbook exists as one entry point with per-channel recipes and upgrade paths, every recipe evidence-backed; `nextcraft guide` mirrors it offline.
- **MH-M6**: Zero silent failures: honest sandbox degrade (loud notice + 503 remediation), honest in-container refusals, honest channel reporting in doctor — the v0.5+ honesty pattern extended to distribution.
## Risks
- **CT 113 single point of failure** (fleet host) — mitigation: compose + config + cron committed as documentation-in-repo (the runbook reproduces the fleet from scratch); founder access path recorded.
- **Registration token expiry mid-P1** — mitigation: bring-up is Wave 1-1's first task; the token's job (3 registrations) completes before anything else depends on it; reset requested immediately after.
- **Cache-server reachability from job containers** (R-5) — mitigation: cache.host + published ports verified at bring-up; volume-mounted pnpm/pip stores are the deterministic fallback (R-12).
- **actions/checkout needs CT egress to github.com** (R-8) — mitigation: probe at 1-1-01; raw git fetch steps as the fallback lane.
- **SEA blob cross-arch behavior** (R-26) — mitigation: probe-first in P3; binfmt fallback scoped to blob generation.
- **Registry push auth failure** (REGISTRY_TOKEN 401/403 on self-serve) — mitigation: founder UI fallback (escalation commits, P2 marks push step as the only blocked piece; ship still completes).
- **docker.io pulls on the release lane** (R-21 note) — bookworm ships docker.io; accepted for v0.8, pre-baked release image documented as the future fix.
- **Runner 3.x config drift** (cache.host/bind_workdir/health_check keys) — mitigation: `gitea-runner generate-config` is the authority at bring-up (R-9 risk 9).
- **Image size growth over releases** (~400-500MB budget, R-18) — mitigation: budget asserted in the smoke gate; multi-stage keeps toolchains out of the runtime layer.
- **Existing v0.4 binary pipeline regression** — mitigation: x64 path byte-identical (checksums + install.sh gates unchanged, pinned by the existing fixture tests).
## User-Facing Surface
1. **Gitea Actions tab** — every push runs the lanes; every tag builds a release (visible, green, machine-made).
2. **`docker run git.coreci.dev/coreci/nextcraft`** — the whole stack, one port, /health, honest degrade notice.
3. **Release downloads** — x64 + arm64 binaries, checksums, .deb packages — every release.
4. **`nextcraft guide`** — offline man-page for admins: channel recipes, upgrade paths, troubleshooting.
5. **`nextcraft doctor`** — channel-aware (container/deb/binary) with the right next-step line.
6. **`deploy/README.md`** — the one-entry-point admin runbook.
7. **Pre-commit** (opt-in for devs) — local hygiene mirroring CI.
## Happy Path (end-to-end, written BEFORE execute)
1. **Founder/rails:** tag push → release workflow builds binary + checksum, attaches to the release, pushes the image (from P2), adds arm64 + debs (from P3) — no human touches an artifact.
2. **Admin, docker channel:** fresh Debian box → `docker run -d -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:v0.7.x` → /health green, site HTML at :8420, boot notice explains the sandbox degrade + the privileged variant pointer.
3. **Admin, deb channel:** `dpkg -i nextcraft_0.7.x_amd64.deb` → advisory doctor → `nextcraft deploy --service` → verified single-port stack.
4. **Admin, binary channel:** `curl … install.sh | sh` → `nextcraft doctor` → `nextcraft deploy --service` (the existing path, unchanged).
5. **Admin, any channel, stuck:** `nextcraft guide` → offline recipe + troubleshooting pointers → resolved or forge link.
6. **Upgrades:** docker = pull + recreate; deb = new deb + deploy --service; binary = install.sh re-run — all idempotent per D-071.
7. **Developer:** clone → pre-commit install → push → CI lanes green before review.
## UX Acceptance Criteria
1. No silent failures anywhere in the new surface: image can't sandbox → loud notice + 503 remediation; in-container miscommands refuse with remediation; doctor names the channel and the correct next step.
2. Every channel ends at the same place: doctor/verify green on a running stack — the admin doesn't need to know the internals.
3. Releases are uniform and machine-made: same asset matrix, same gates (checksum + version), no manual artifact steps.
4. The guide works offline and agrees with the runbook (parity-tested — divergence is a test failure, not a doc drift).
5. Docs match reality: every recipe carries an evidence pointer; stale docs are a P0 at final review, not a footnote.
1. **Honest states everywhere:** every new surface renders real loading/error/empty states — no spinners that lie, no silent failures, no fake data.
2. **401 → honest logged-out state:** SessionRequiredError renders the logged-out state with a login CTA — never a retry loop, never a silent redirect.
3. **402 upgrade CTA visible with correct limits:** the payload's reason drives the copy (no package vs limit reached); the CTA routes to the employer billing surface; limits shown are the store's truth. (G-320b: the 402 payload's posts_used/posts_limit are byte-stable zeros, never rendered — real limits render from the entitlement view only.)
4. **Claim-pending state visible to the employer:** the dashboard says "claim submitted, awaiting review" until the operator decides — never presented as verified before approval.
5. **Admin sees the claim queue with evidence:** the domain-match signal renders as advisory (match=false is the corpus norm); approve/reject are explicit operator actions with an audit trail.
6. **Talent pages remain mock-labeled:** the honest-surface rule cuts both ways — never fake-convert, never leave a convertible surface on mock; talent stays mock because no backend computes it.
+32 -4
View File
@@ -16,13 +16,40 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
---
## Milestone v0.9 — Payment Engine (ACTIVE, Feature, started 2026-09-29)
## Milestone v0.10 — B2B Employer Network (ACTIVE, Feature, started 2026-09-30)
**Scope (D-074, auto-decided at SPECIFY under full autonomy D-007; founder scope question left open post-v0.8):** Activates **REQ-F-018 — payment processing and subscription management** as REQ-9-001..005. The pricing page (REQ-017, critical since v0.1) gains its real backend: posting packages (single, bundle, enterprise), talent-access plans for employers, and subscription tiers — with checkout, provider webhooks, entitlement enforcement on posting creation, and a subscription lifecycle (renew/cancel/upgrade/downgrade) under a provider-agnostic protocol (D-014 mock-first pattern). Rationale: the guiding outcome (≥1,000 placements via the marketplace) requires a monetized, self-serve employer side; the marketplace engine (v0.6) built the supply side, ship-anywhere (v0.8) made the stack installable anywhere — payments is the only high-priority requirement deferred since v0.3 that directly unblocks employer self-serve (A-407: "real employer auth lands post-payments with the B2B employer network") and CIRR-style placement tracking needs paid postings to track. Confidence 0.80 ≥ threshold 0.60 (D-007).
**Scope (D-085, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-09-30):** Real employer auth/accounts and employer self-serve — the milestone every prior decision sequences next: A-407 ("Real employer auth lands post-payments with the B2B employer network" — payments shipped v0.9), A-401 (employer self-serve posting management deferred pending employer auth), D-076/D-079 (operator-mediated checkout + contact-path CTAs were honest *because* no employer principals exist). Employers become principals: registered accounts with sessions, claiming their existing employer entity (the v0.6 `EmployerRow` ingested data), self-serve posting management (draft → published → closed on their own entities), self-serve checkout (the v0.9 billing catalog with employer-principal buyers instead of operator mediation), and the employer dashboard surface converting from the v0.1 mockup to real data. Rationale: the guiding outcome (≥1,000 placements) needs a self-serve employer side; the supply side (v0.6), monetization rails (v0.9), and install-anywhere distribution (v0.8) are all in place — the employer network is the missing B2B surface that unlocks CIRR tracking (REQ-F-020 needs employer-side placement data) and closes the last mock-only surface. Confidence 0.80 ≥ threshold 0.60 (D-007).
**Out of scope:** real employer auth/accounts (B2B employer network — deferred, lands post-payments per A-407), multi-currency, tax/VAT handling beyond passthrough, invoice PDF generation, Stripe-specific UI components, CIRR tracking (REQ-F-020, deferred).
**Out of scope:** CIRR placement tracking (REQ-F-020, deferred), human tutor marketplace (REQ-F-019, deferred), multi-tenant SSO/OIDC enterprise login (email+password at pilot), employer team/multi-seat management, posting analytics beyond the existing enriched views.
**Success (milestone-level):** an employer (via admin-mediated flow at pilot) can purchase a posting package or talent-access subscription through a real Stripe-compatible checkout; webhooks update entitlements automatically; posting creation enforces package limits honestly; subscription lifecycle transitions (renew/cancel/upgrade) work end-to-end; the pricing page reflects real catalog data; all provider integrations are mock-first (tests never call Stripe).
**Success (milestone-level):** an employer can register an account, claim/verify their employer entity, log in, manage their own postings through the full lifecycle, purchase posting packages/talent-access plans through self-serve checkout with entitlement enforcement applying to their own posts, and see real data in the employer dashboard; admin routes retain operator oversight (impersonation-free, audit-logged); all auth is session-based with hashed credentials, rate-limited, and fail-closed on unconfigured secrets; the mock-first doctrine holds (no external auth provider, tests never call a cloud).
## Clarify decisions (D-086+, CLARIFY stage full autonomy, 2026-09-30)
| ID | Decision | Rationale | Confidence |
|----|----------|-----------|------------|
| D-086 | Password KDF = **stdlib `hashlib.scrypt`, zero new dependencies**: per-password 16-byte random salt (`secrets.token_bytes`), OWASP-floor params N=2^14/r=8/p=1, dklen=32, stored self-describing as `scrypt$16384$8$1$<salt_b64>$<hash_b64>` so params can evolve without invalidating stored rows; verification compares derived bytes via `hmac.compare_digest`; params are module constants pinned by test (the D-083 PAST_DUE_GRACE_DAYS shape). | passlib is unmaintained and ships no type stubs — adding it would force the FIRST `ignore_missing_imports` mypy override, breaking the v0.7 pyproject regime ("currently NONE" — pyproject.toml:143-151); bcrypt adds a compiled dep to the deb/docker/arm64 matrix (D-069) for nothing; scrypt IS a library-grade memory-hard KDF (OpenSSL-backed, probe-verified in the shipped venv) and the tree's crypto precedent is stdlib-only end-to-end (HMAC-SHA256 webhooks D-077, ingest tokens v0.4.7, `secrets` minting). pypdf was "the ONLY new v0.6 dependency" — the zero-dep discipline holds. | 0.80 |
| D-087 | Employer sessions = **server-side rows in the new `employer_sessions` table (8th D-027 family), opaque token minted per login, stored hashed**: token = `secrets.token_urlsafe(32)` (CSPRNG — per-boot entropy inherent, the factory.py mint precedent); the row stores only `sha256(token)` and lookups re-hash the presented token, so a DB-file leak never yields usable credentials (the G-108 secrets doctrine extended to storage). TTL = fixed 7 days from login (module constant, test-pinned) evaluated LAZILY at read (G-306 — no timer); logout DELETEs the row + audit event (revoke = lookup miss → 401); multiple concurrent sessions per account (per-device rows) are allowed. The agents/ `InMemorySessionStore` is NOT reused — it is LRU-evicted chat context (D-019), a different concern; principal state is a durable D-027 row like everything else persisted. | REQ-10-001 already fixes "server-side store" (signed tokens/JWTs are off-spec); opaque random tokens need no signing key (server-side lookup is the truth source) and no per-boot secret to manage; hashed-at-rest is the standard session-store discipline at zero cost. | 0.85 |
| D-088 | Auth throttling = **in-memory sliding windows in api/ (process-local, the G-5 precedent)**: (a) per-EMAIL failed-login window — default 5 failures / rolling 15 min → 429 (settings knob, the `identity_submits_per_min` shape); (b) a global per-min cap on login+register attempts (the `_check_global_create_rate` deque, api/sandboxes.py:123) → 429, bounding cross-email credential-stuffing volume; successful login clears the email's failure window; 429s carry the honest "wait" detail. Keyed by the SUBMITTED EMAIL, not client IP — behind HAProxy every request's `request.client.host` is the proxy (the tree has zero IP-trust machinery; D-039 single-port). | The identity `_SubmitRateLimiter` (api/identity.py:97-116) and sandbox create-cap are the proven in-tree throttle patterns — same module-state acceptability (D-019: one process, one box); IP-based keys would be fake security on this topology; brute-force economics at pilot are bounded by the two windows without any new infra. | 0.80 |
| D-089 | Claim/verify = **claim → pending → admin decision; evidence recorded, never auto-activating at pilot**: `POST /v1/employers/claims {employer_id}` (authenticated) creates an `employer_claims` row status=pending with computed evidence `{account_email_domain, entity_website_domain, match}` (normalized registrable-domain comparison — stored as provenance); `employer_accounts.employer_id` is UNIQUE and set on approval (the REQ's "one account : one entity" UniqueConstraint); one PENDING claim per entity + one APPROVED owner per entity enforced check-first in the store (the `insert_application` precedent); rejection frees the entity for a new claim. Admin approve/reject routes (require_admin) set decided_by/decided_at + append `employer_audit_events` (the billing_events provenance shape). Auto-approval on domain match is DEFERRED: without email verification the match is forgeable, so the admin queue DISPLAYS the signal but the operator decision activates. | The seeded corpus websites are example.com placeholders — no real verification surface exists at pilot; REQ-10-002 explicitly retains "admin routes for claim review/approve/reject (operator oversight)"; an auto-approve on forgeable evidence would be a dishonest gate (the A-304 doctrine — never present unverified as verified). | 0.80 |
| D-090 | Session transport = **`Authorization: Bearer <session-token>`** with a `require_employer_session` dependency (api/employers.py — the require_admin extract→verify→return shape): 401 on missing/expired/unknown token (the webhook-HMAC credential precedent), 403 stays the authorization failure (cross-entity access); the dep returns the session + account context (claimed employer_id included). NOT a cookie: D-039 static export has no Next middleware — the client attaches the header explicitly on every fetch (A-002); an explicit header is CSRF-immune by construction (no ambient credential, no SameSite machinery). CORS `allow_headers` gains `"Authorization"` (main.py:341 — the E87-class lesson: the v0.3 PUT preflight omission broke every cross-origin Save; tests/api/test_cors.py extended to pin it) for the two-port dev + D-038 network-mode topologies. Token namespaces cannot collide: admin routes compare against the env token, employer routes hash-lookup sessions — a token of one class fails the other's gate. Operator routes live in a separate `api/employers_admin.py` mounting `require_admin` imported from marketplace_admin (the billing_admin.py:27 import precedent). | Parallel to require_admin's dual-header acceptance but single-header for one principal class; same-origin production fetches skip CORS entirely; the dev topology needs the allow-list entry or every employer preflight dies — caught before it ships this time. | 0.85 |
| D-091 | Entitlement reuse = **one shared api-layer helper, two principal gates composing it**: extract the 402 gate from marketplace_admin.create_posting (lines 181-220) into a shared api/ function `consume_posting_entitlement(store, employer_id, upgrade_cta)` raising the canonical 402 dict payload `{reason, posts_used, posts_limit, upgrade_cta}`; the admin route passes the admin-checkout CTA, the employer route the employer-checkout CTA — reason/posts_* identical, `UpgradeRequiredError` contract unchanged (D-080/D-084 wire stability). `consume_posting` itself (G-304 single conditional UPDATE) is untouched — no store change, no billing/→api/ boundary violation (the helper lives in api/ beside both callers, D-027/D-048 territory preserved). Employer checkout `POST /v1/employers/billing/checkout {item_id}` mirrors billing_admin.create_checkout with the buyer = the session's claimed entity (403 + claim-CTA when unclaimed; metadata operator_ref → account provenance) and an employer-scoped mock-complete arm mounted only under provider=mock, completing one's OWN session (D-082 transposed to the employer principal; success/cancel URLs from public_base_url per G-310). | REQ-10-004 demands the exact v0.9 check parameterized, not reimplemented — extraction is the only no-drift shape; the billing webhook/provider/store path is reused verbatim (REQ-10-005 "the v0.9 billing module reused"), only the principal derivation changes. | 0.85 |
| D-092 | Web conversion = **employer section in engine-client.ts (no new client module) + sessionStorage `nc-employer-session`**: engine-client.ts is THE fetch module (identity/billing/marketplace sections already live there; pins.test.ts pins its content) — the employer section adds register/login/logout/me, claim, own-postings CRUD/publish/close, own-checkout, own-entitlement + a `SessionRequiredError` (401 → the G-308 honest logged-out state, never a retry loop; logout clears storage + calls the revoke route; every fetch aborts via AbortController — the A-406 board-client discipline). Storage = sessionStorage (the G-308 admin-token wrapper is the only in-tree credential-storage pattern; tab-scoped bounds XSS token exposure vs localStorage's persistent copy; no pilot requirement demands cross-tab persistence). Conversion scope: postings page (real own-postings lifecycle), overview page (real active-postings count + per-posting applicant counts from the applications table; talent-match/placement metrics stay mock-labeled — no backend computes them), employer profile (claimed entity via the existing public GET /v1/marketplace/employers/{id}), new /employer/login + /employer/register pages; **talent search + candidate profile pages stay the v0.1 mock** (no candidate backend exists in any milestone — v0.6 built postings/search over POSTINGS; a talent-access search backend is honestly out of scope, their mock imports remain). | One client module keeps parseError/error-class discipline in one place (a second module would fork the 402/401 discrimination); mock stays only where no real data source exists — the honest-surface rule cuts both ways (never fake-convert, never leave a convertible surface on mock). | 0.78 |
| D-093 | Wire types = **`packages/types/employer.ts`** (EmployerAccount, EmployerClaim, EmployerPublicProfile reuse where shapes coincide — PostingSummary is reused from marketplace.ts, never duplicated, and the entitlement view reuses the v0.9 shapes) dual-schema pinned field-for-field against `ai_service/employers/base.py`'s pydantic contracts in `apps/web/tests/pins.test.ts` (the G-108 discipline; the billing.ts pin block, pins.test.ts:202-230, is the template — every field asserted both sides). | Every prior cross-boundary wire (PostingSummary, SearchResult, Package/Plan) is pinned this way (D-084); the employer surface is a new cross-boundary contract and gets the same rail from day one. | 0.85 |
| D-094 | Admin network overview = **the admin marketplace moderation page gains the network sections** (app/(admin)/admin/moderation/page.tsx — where the v0.9 EmployerEntitlementPanel already landed, REQ-9-004's operator lane): employer accounts list (email, claimed entity, entitlement state columns), claim queue (evidence + domain-match signal, approve/reject actions), fed by new operator routes in api/employers_admin.py (require_admin + audit events): GET /v1/employers/admin/accounts, GET /v1/employers/admin/claims, POST .../claims/{id}/approve, POST .../claims/{id}/reject, GET .../audit — web transport via the existing `adminToken()` G-308 sessionStorage wrapper + honest 403 locked states. | REQ-10-005 names "the operator oversight lane"; the moderation page is the established admin marketplace surface (employer claims are the marketplace-moderation concern — REQ-025's verification queue was its v0.1 mock ancestor); one admin surface keeps the operator's one-glance workflow instead of fragmenting across pages. | 0.80 |
| D-095 | Migration/compat = **8th D-027 store on the shared DB file, `SQLModel.metadata.create_all` idempotent — zero migration**: `SQLiteEmployerStore` mirrors billing/store.py mechanics exactly (WAL / synchronous=NORMAL / busy_timeout / foreign_keys pragmas at connect, sa_type portable columns, `_validate`/`_normalize` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware↔naive normalization); `create_all` checkfirst semantics add the 4 tables (`employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events`) to existing DBs at next boot — the billing store shipped the same way against the marketplace-era file (proven compat). Lifespan wiring: the state-injection override (main.py `if getattr(app.state, "employer_store", None) is None: construct from settings.db_path`) + `close()` in the finally block (the billing_store wiring, main.py:182-190/325). The `employer_` prefix is mandatory — the bare `employer` table name is taken by marketplace's EmployerRow (store.py:145). | The 7 prior stores never broke an existing DB via this exact pattern (D-027 family replication is the most-repeated store contract in the tree); REQ-10-001's table names already carry the prefix. | 0.90 |
| D-096 | Testing doctrine = **real flows against create_app + TestClient, tmp_path DBs, real KDF, zero network by construction**: `tests/employers/` (the billing suite layout): register/login/logout/me, throttle 429 windows (both knobs), claim → admin approve → own-posting lifecycle → cross-entity 403s, entitlement 402 arms via direct store grants (the test_entitlement_enforcement.py:60 precedent), employer checkout + employer mock-complete E2E through the REAL webhook path (the D-082 proof shape). Password hashing runs the REAL scrypt in tests (N=2^14 ≈ tens of ms per hash — honest, never a fake-hash path; tests never assert hash bytes, only accept/reject behavior; salts random per hash — determinism is at the behavior level, the mock-provider verdict lesson). No MockTransport needed — auth has no external calls; the conftest zero-network guard stays satisfied; mypy green with no new overrides (stdlib only). | The suite's contract is behavior over the real app (billing/billing_admin/entitlement tests are the template); a reduced-KDF test profile would fork the code under test — the exact drift the honest-surface rule forbids. | 0.85 |
| D-097 | Gates = **no learner gates on employer routes; email verification + self-serve password reset deferred (honest out-of-scope)**: employer routes compose ONLY the employer session gate — no `require_verified_age`, no G-5 learner allowlist (employers are a third principal class, not learners; D-043's composition on the learner routes — apply/resume/sandbox — is untouched and keeps allowlist → verified 18+ → rate caps). Registration stores email UNVERIFIED (displayed as such; that is why claim evidence is advisory per D-089); no email/notification infra exists (the A-408 out-of-band precedent) so email verification and self-serve password reset are deferred and documented honestly in the surface copy; admin retains remediation authority via the operator routes + `employer_audit_events` trail. | Employer accounts are B2B adults buying posts, not gated minors — the 18+/16+ gates exist to protect learners on learner surfaces (D-043); inventing an email-verification path without any mail transport would be security theater (A-304: never present unverified as verified). | 0.85 |
---
## Milestone v0.9 — Payment Engine (COMPLETE, Feature, 2026-09-29 → 2026-09-30, shipped as v0.8.6)
**Scope (D-074, auto-decided at SPECIFY under full autonomy D-007; founder scope question left open post-v0.8):** Activates **REQ-F-018 — payment processing and subscription management** as REQ-9-001..005. The pricing page (REQ-017, critical since v0.1) gains its real backend: posting packages (single, bundle, enterprise), talent-access plans for employers, and subscription tiers — with checkout, provider webhooks, entitlement enforcement on posting creation, and a subscription lifecycle (renew/cancel/upgrade/downgrade) under a provider-agnostic protocol (D-014 mock-first pattern). Rationale: the guiding outcome (≥1,000 placements via the Nextcraft marketplace) requires a monetized, self-serve employer side; the marketplace engine (v0.6) built the supply side, ship-anywhere (v0.8) made the stack installable anywhere — payments is the only high-priority requirement deferred since v0.3 that directly unblocks employer self-serve (A-407: "real employer auth lands post-payments with the B2B employer network") and CIRR-style placement tracking needs paid postings to track. Confidence 0.80 ≥ threshold 0.60 (D-007).
**Out of scope (landed in v0.10):** real employer auth/accounts (B2B employer network), multi-currency, tax/VAT handling beyond passthrough, invoice PDF generation, Stripe-specific UI components, CIRR tracking (REQ-F-020, deferred).
**Success (milestone-level):** an employer (via admin-mediated flow at pilot) can purchase a posting package or talent-access subscription through a real Stripe-compatible checkout; webhooks update entitlements automatically; posting creation enforces package limits honestly; subscription lifecycle transitions (renew/cancel/upgrade) work end-to-end; the pricing page reflects real catalog data; all provider integrations are mock-first (tests never call Stripe). — ACHIEVED, all 5 REQ complete, shipped as v0.8.6 (release with full asset matrix + registry image per D-064).
## Clarify decisions (D-075+, CLARIFY stage full autonomy, 2026-09-29)
@@ -305,6 +332,7 @@ The following remain deferred beyond v0.3 and will be activated in subsequent mi
| ID | Decision | Rationale | Outcome |
|----|----------|-----------|---------|
| D-085 | v0.10 = B2B Employer Network (auto-decided at SPECIFY, full autonomy D-007; founder scope confirmed 2026-09-30) | Real employer auth/accounts + self-serve: registered employer accounts with sessions, employer-entity claim/verify, self-serve posting management (draft→published→closed on own entities), self-serve checkout (v0.9 catalog with employer-principal buyers, superseding D-076 operator mediation), employer dashboard real-data conversion. Rationale: A-407 sequences employer auth post-payments (payments shipped v0.9); A-401/D-076/D-079 deferred self-serve precisely pending this; the employer dashboard is the last mock-only surface; CIRR tracking (REQ-F-020) needs employer-side data. Confidence 0.80 ≥ threshold 0.60 (D-007). | Milestone scope locked at SPECIFY; tags on v0.9.x line; feature milestone; branch milestone/v0.10-b2b-employer-network |
| D-001 | Milestone v0.1 = UI/UX prototype only, no business logic | Founder directive: validate UX before building backend. Prototype must be agreed upon before proceeding to business logic. | Scope locked to frontend surfaces with mock data |
| D-002 | MAJOR 0 until MVP released | Founder directive: remain on v0.x until MVP is validated. Signals pre-release status. | All tags are v0.x.y until MVP agreement |
| D-003 | TypeScript monorepo (pnpm/turborepo) + Next.js | Unified codebase for all 4 surfaces. Shared component library, types, mock data. Next.js App Router for route-based surface separation. Python AI services deferred to later milestones. | Monorepo structure with apps/web + packages/* |
+36
View File
@@ -1,5 +1,41 @@
# Nextcraft — REQUIREMENTS.md
## v0.10 Requirements (Active — B2B Employer Network, Feature; started 2026-09-30)
**Milestone type: Feature** — real employer auth/accounts + employer self-serve (the B2B employer network; activated per D-085). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`. Auth is session-based with hashed credentials (mock-first doctrine: no external auth provider, tests never call a cloud); admin operator routes are retained unchanged (D-043 composition untouched).
### Employer Auth Foundation
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-001 | Employer auth foundation: `ai_service/employers/` module (D-031 extend pattern): employer account store (8th D-027 family — `employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events` tables, D-095) with SQLModel typed access; password hashing via stdlib `hashlib.scrypt` (D-086 — zero new deps, per-password random salt, self-describing params); session tokens as server-side store rows with the token stored hashed, fixed 7-day TTL evaluated lazily, revoke-on-logout (D-087); register/login/logout/me routes (`/v1/employers/auth/*`, Bearer session transport per D-090); rate limiting on auth routes (D-088 — per-email failed-login window + global attempt cap, the G-5 in-memory pattern); credentials + secrets env-only (D-014); fail-closed when unconfigured | critical | 1 | active |
### Employer Entity Claim
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-002 | Employer-entity claim/verify: an authenticated employer account claims an existing `EmployerRow` entity (the v0.6 ingested data) via a claim route; claims enter a pending state with recorded evidence (email-domain vs entity-website comparison — advisory signal, D-089) and activate via admin approval at pilot; claims recorded with provenance (`employer_claims`); one account : one entity (UniqueConstraint); admin routes for claim review/approve/reject (operator oversight retained, audit-logged per D-089); claimed-entity authorization: employers can only manage postings/purchases for their own entity | critical | 2 | active |
### Employer Account Surface
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-003 | Employer dashboard real-data conversion: the employer surface switches from v0.1 mock imports to runtime client fetches (A-406 pattern — AbortController, honest loading/error/empty states) via the engine-client employer section + sessionStorage session state (D-092); employer auth UI (login/register pages, session-gated dashboard shell, honest logged-out states); posting management reads the employer's own postings via the authenticated surface (draft → published → closed lifecycle visible); employer profile read from the claimed entity (enrichment retained); talent search + candidate profile pages stay the v0.1 mock (no candidate backend exists — honest out-of-scope, D-092); wire types in `packages/types/employer.ts` dual-schema pinned (G-108 discipline, D-093) | critical | 3 | active |
### Self-Serve Posting Management
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-004 | Self-serve posting management: employer-principal posting routes (`/v1/employers/postings/*`) — create draft, edit own drafts, publish own drafts, close own postings, list own postings with status facets; authorization: the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the admin-gate-first order preserved, G-311) with the 402 CTA unchanged, D-080 consumption model intact, via the shared api-layer helper extracting the v0.9 gate (D-091 — no duplication); posting creation consumes from the employer's own packages (the v0.9 entitlement check parameterized by the session's employer_id instead of request body); the admin posting routes remain (operator path unchanged, provenance-recorded); 18+ gate N/A (employer surface is not learner-facing; the D-043 learner gates untouched, D-097) | high | 4 | active |
### Self-Serve Checkout
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-005 | Self-serve checkout + admin network overview: employer-principal checkout (`/v1/employers/billing/checkout`) creating provider sessions for the catalog (the v0.9 billing module reused — provider protocol, webhooks, entitlement grants unchanged; buyer = session employer instead of operator mediation, superseding D-076 while admin routes stay as the operator fallback; D-091 composes the existing checkout route logic with the session principal); entitlement view for the employer's own purchases/subscriptions (current package, posts used/limit, expiry — honest states); pricing page CTAs convert from contact-path to employer-login-path (superseding D-079); admin surface gains the network overview on the marketplace moderation page (employer accounts list, claim queue with evidence signals, per-account entity + entitlement state — the operator oversight lane, D-094) | critical | 5 | active |
---
## v0.9 Requirements (Complete — Payment Engine, Feature; shipped as v0.8.6)
**Milestone type: Feature** — payment processing and subscription management (REQ-F-018 activated per D-074). Shipped on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`. All 5 requirements complete: the billing module + 7th D-027 store + HMAC webhook core (REQ-9-001), the catalog + pricing surface (REQ-9-002), the checkout/webhook flow with per-session exactly-once grants (REQ-9-003), posting entitlement enforcement with honest 402s (REQ-9-004), the full subscription lifecycle + queryable audit log (REQ-9-005). — payment processing and subscription management (REQ-F-018 activated per D-074). Tags on the v0.8.x line (P0 → v0.8.0 … P6 → v0.8.6 = milestone release); branch `milestone/v0.9-payment-engine`. Provider-agnostic protocol, mock-first (D-014 pattern): tests never call Stripe; the real provider drops in by env config without API changes.
+411 -1
View File
@@ -1,4 +1,414 @@
# Nextcraft — RESEARCH.md (v0.9 Phase 0 RESEARCH — Payment Engine)
# Nextcraft — RESEARCH.md (v0.10 Phase 0 RESEARCH — B2B Employer Network)
## v0.10 research conclusions (Phase 0 RESEARCH, 2026-09-30)
In-tree census of the employer-auth/self-serve surface, 2026-09-30.
Every conclusion carries its evidence (file+line probes, live venv
probes). Binding clarify decisions D-086..D-097 are NOT re-litigated;
this research operationalizes them (the milestone is almost entirely
in-tree precedent — 10 milestones of patterns; the one external fact,
scrypt availability/cost in the shipped runtime, was probe-verified).
Probes: apps/ai-service/.venv python 3.11.2 (hashlib.scrypt + timing +
maxmem); api/marketplace_admin.py (require_admin + create_posting
gate), billing/store.py + identity/store.py + marketplace/store.py
(the D-027 family), billing_admin.py + billing.py + base.py + mock.py
+ factory.py (checkout + webhook paths), main.py (lifespan + routers +
CORS), config.py + .env.example (knob shapes), api/identity.py +
api/sandboxes.py + api/marketplace.py (throttle precedents),
tests/conftest.py + tests/billing/ + tests/marketplace/conftest.py
(suite layout), seed_corpus.json (claim-evidence reality),
apps/web/lib/engine-client.ts + employer/admin pages + pins.test.ts
(web conversion surface).
**R-55. scrypt in the shipped runtime: available, ~50 ms/hash at
D-086 params, default maxmem sufficient — pin nothing below 32 MiB.**
Probe against apps/ai-service/.venv (Python 3.11.2, requires-python
">=3.11", pyproject.toml:9): `hashlib.scrypt` is a built-in
(`<built-in function scrypt>`, OpenSSL-backed). Cost at N=2^14/r=8/p=1/
dklen=32: best-of-5 = **47.3 ms per hash** (single ~59 ms) on this box
— register and login each perform exactly ONE derivation, inside
D-096's "tens of ms, honest" budget; a full register→login→claim test
flow stays far under the 60s pytest timeout with the REAL KDF (tests
assert accept/reject behavior only, never hash bytes). Memory:
128·N·r = **16 MiB** working set; CPython's default maxmem (32 MiB)
admits these params with **no maxmem argument** (probe: default OK;
explicit `maxmem=32*1024*1024` OK; `maxmem=1024*1024` raises
`ValueError: ... memory limit exceeded`). Record for D-086
implementation: do NOT pass a maxmem below ~17 MiB; if pinned for
explicitness use 33554432. Storage format per D-086:
`scrypt$16384$8$1$<salt_b64>$<hash_b64>` (16-byte
`secrets.token_bytes` salt, self-describing so params evolve without
invalidating rows), verify via `hmac.compare_digest` over derived
bytes; params are module constants pinned by test (the
`PAST_DUE_GRACE_DAYS` shape, billing/store.py:58-61).
**R-56. The require_employer_session dep mirrors require_admin's
extract→verify→return shape; 401 = credential, 403 = authorization.**
`_extract_admin_token` (api/marketplace_admin.py:46-58) is a
dual-header extractor — `X-Admin-Token`
(`Annotated[str | None, Header(include_in_schema=False)]`) wins, else
`Authorization: Bearer <token>` via `partition(" ")` +
`scheme.strip().lower() == "bearer"`; None when neither carries a
candidate. `require_admin` (61-85) is a FastAPI dependency composed
`Depends(_extract_admin_token)` + `Depends(get_settings)` that RETURNS
`Settings`; 403 "admin token required" when the configured token is
unset OR no candidate (fail-closed, D-057), 403 "invalid admin token"
on `secrets.compare_digest` mismatch — compared on UTF-8 encodings
(`surrogatepass` on the candidate) so any bytes compare honestly,
constant-time, without the TypeError 500 (final-review F-1). No
HTTPBearer anywhere — plain Header params are the house pattern. The
employer dep (D-090) mirrors with: single `Authorization: Bearer`
extract (one principal class), sha256(token) hash-lookup against
`employer_sessions` → **401** on missing/expired/unknown (credential
failure; TTL evaluated lazily at read — G-306, no timer), **403
reserved for cross-entity authorization** (valid session touching
another employer's posting), returning session + account + claimed
employer_id. Token namespaces cannot collide: admin routes compare
against the env token, employer routes hash-lookup — one class fails
the other's gate. Second-router import precedent:
`from .marketplace_admin import require_admin`
(api/billing_admin.py:27). **Security finding:** the app-wide 422
scrubber (main.py:398-424) redacts `err["input"]` only for
`pii_fields = frozenset({"date_of_birth"})` (line 401) — a pydantic
422 on an employer register body would ECHO the raw password into the
response/client logs; the employers milestone must add `"password"`
(and `"email"`) to the scrub set (open risk 3, security-auditor
territory).
**R-57. The 8th-store recipe is fully specified by the 7th (D-095):
billing/store.py mechanics + the `employer_` prefix is MANDATORY.**
Replication checklist, all probe-verified: SQLModel table classes in
`store.py` with `__tablename__` (bare `employer` is TAKEN by
marketplace's EmployerRow, marketplace/store.py:144 — every v0.10
table carries the `employer_` prefix); `sa_type=String/JSON`
portable columns (bare Literal annotations crash sqlmodel inference —
identity/store.py:48-51); `_validate()`/`_normalize()` hooks +
module `_as_utc()` tz re-labeling (billing/store.py:362-366);
`_sqlite_connect` pragmas WAL / synchronous=NORMAL / busy_timeout=5000
/ foreign_keys=ON (369-376); Protocol + SQLite impl pair
(`EmployerStore` / `SQLiteEmployerStore`); `__init__` builds the
engine (`check_same_thread=False`) + calls `create_schema()`
(549-556); `create_schema` = `SQLModel.metadata.create_all`
(558-560) — idempotent checkfirst semantics add the 4 tables to
existing DBs at next boot (the proven billing-store compat, zero
migration); IntegrityError SURFACED never swallowed
(identity/store.py:152; billing/store.py:802-804); domain errors as
module Exception classes beside the Protocol (billing/store.py:
379-388). Unique/check-first patterns to copy: per-key UniqueConstraint
+ check-first insert (`insert_application` returns (row, created) —
marketplace/store.py:341-347, impl ~883); rowid tie-break ordering
(identity/store.py:174-183). 4-table schema (D-087/D-089):
`employer_accounts` (id PK `acc-`-style, email UNIQUE + indexed,
password_hash str, employer_id UNIQUE nullable-until-claim-approval,
created_at); `employer_sessions` (id, token_hash UNIQUE — stores ONLY
`sha256(token)`, never the token, account_id indexed, created_at,
expires_at = login + SESSION_TTL_DAYS module constant);
`employer_claims` (id, account_id, employer_id indexed, status
pending|approved|rejected, evidence JSON `{account_email_domain,
entity_website_domain, match}`, created_at, decided_by, decided_at);
`employer_audit_events` (auto id PK, account_id/employer_id nullable
indexed, kind/name, detail JSON, operator, created_at — mirrors
BillingEventRow, billing/store.py:337-359). No `service.py` anywhere
in-tree — routes compose stores via api/ DI; the KDF lives in a small
`passwords.py` (the billing/webhooks.py crypto-core precedent), the
claim-evidence helper in base.py or store.py.
**R-58. The D-091 extraction source: gate AFTER auth, BEFORE insert;
the 402 dict hardcodes posts_used=0/posts_limit=0 — preserve exactly.**
`create_posting` (api/marketplace_admin.py:157-251) order: `require_admin`
dep (162) → billing-store presence guard (183-192:
`getattr(request.app.state, "billing_store", None)`; None → **503**
"billing entitlement store is unavailable…" — the fail-closed
final-review fix, never an ungated 201) → `consume_posting()`
(194) → two 402 arms (195-220) → posting insert (221-243). **Gate runs
before insert — a 402 consumes nothing, creates nothing.** Canonical
402 dict (both arms): `{reason, posts_used, posts_limit, upgrade_cta}`
with reason ∈ {`no_active_package`, `posting_limit_reached`} and
**posts_used/posts_limit hardcoded 0 in BOTH arms** (lines 200-201,
213-214) — the extraction preserves this byte-shape (the wire
contract `UpgradeRequiredError`, engine-client.ts:93-101, consumes
only reason + upgrade_cta; D-080/D-084 stability). `consume_posting`
itself (billing/store.py:968-1027 — G-304 single conditional UPDATE,
rowcount-checked; NoActivePackage on zero rows, PackageExhausted when
all full/expired) is UNTOUCHED. The D-091 helper
`consume_posting_entitlement(store, employer_id, upgrade_cta)` lives
in **api/** beside both callers (no billing/→api/ boundary violation),
raising `HTTPException(402, detail=<same dict>)`; admin route passes
the admin-checkout CTA, employer route the employer-checkout CTA.
Employer-arm composition mirrors "admin gate still first"
(tests/billing/test_entitlement_enforcement.py:161-170): session gate
(401/403) BEFORE any billing decision; same 503 store-missing guard.
**R-59. Employer checkout mirrors billing_admin.create_checkout
verbatim — only principal derivation + metadata provenance change.**
Source route (api/billing_admin.py:77-146): `require_admin` →
`store.get_catalog_item(item_id)` → **404 "unknown catalog item"** →
mode = subscription iff kind==plan → success/cancel URLs from
`settings.public_base_url` (G-310:
`{origin}/marketplace/pricing?checkout=success|cancelled`; empty under
mock — stripe factory validation rejects empty at boot) →
`insert_checkout_session` FIRST with unique placeholder
`provider_session_id=f"pending-{uuid4().hex[:16]}"` (final-review fix:
bare "pending" collided on the UNIQUE constraint when two checkouts
raced) → `CheckoutSessionSpec` (billing/base.py:137-164: mode, item_id,
price_cents + item_name D-078 snapshot, interval, success_url,
cancel_url, `client_reference_id` = LOCAL row id ≤200 chars,
`metadata={"employer_id": …, "operator_ref": "operator"}`) →
`provider.create_checkout_session(spec)` via
`request.app.state.payment_provider` → `bind_provider_session_id` →
`CheckoutResponse{session_id, provider_session_id, url, mode}`.
Employer arm (D-091): `POST /v1/employers/billing/checkout {item_id}`
— session-gated; unclaimed account → **403 + claim-CTA**; buyer = the
session's CLAIMED entity; metadata employer_id = claimed entity,
operator_ref → the account's provenance (D-076's "operator" becomes
account provenance); everything else verbatim. Mock-complete transpose
(D-082/G-301): `POST /v1/billing/admin/mock/complete`
(billing_admin.py:152-227) — admin-gated + `isinstance
MockPaymentProvider` defense-in-depth 404; session resolved from
LOCAL rows by provider_session_id (G-302 — payloads are untrusted ID
references); `confirm_completion` ValueError → recover via
`retrieve_session` (404 unknown-after-restart / 409 not completable /
already-complete fall-through); `provider.mint_event(
"checkout.completed", {session_id, payment_status})` →
`process_webhook_event(store=…, provider=…, raw_body=…,
signature_header=…)` (api/billing.py:58-120) — the REAL webhook path,
direct handler call. The employer arm mounts ONLY under provider=mock
(factory-conditional, the billing precedent), is
employer-SESSION-gated, and completes one's OWN session (cross-session
completion = the G-301 hole class; open risk 5). Webhook + grant
reuse unchanged: `/v1/billing/webhook` (billing.py:329-344) +
`_grant_entitlement` (billing.py:249-326; G-303 per-session
exactly-once via `purchases.checkout_session_id` UNIQUE) serve
employer checkouts verbatim — only session-row provenance differs.
Provider factory (billing/factory.py:43-71) untouched.
**R-60. main.py wiring sites (all four) + the knob shapes.**
(a) **Lifespan store block**: the billing template at main.py:182-190
(`if getattr(app.state, "billing_store", None) is None: from
.billing.store import SQLiteBillingStore; app.state.billing_store =
SQLiteBillingStore(db_path=settings.db_path)`) — the `employer_store`
wiring lands directly after (~line 191) with the same guard; its
`close()` lands in the finally block beside
`app.state.billing_store.close()` (line 325). (b) **Router mounting**:
feature routers mount with inline imports + comments (identity/
marketplace at 366-372, billing at 378-382, sitemap 387-389) —
api/__init__.py exports only the v0.2-era routers, so `api/employers.py`
+ `api/employers_admin.py` follow the inline pattern beside the
billing block. (c) **CORS**: `allow_headers=["Content-Type"]`
(main.py:341) gains `"Authorization"` (D-090) for the two-port dev +
D-038 network-mode topologies (same-origin production fetches skip
CORS); E87-class lesson — v0.3 shipped allow_methods without PUT and
every cross-origin Save died preflight — so tests/api/test_cors.py
(the policy pin, 82 lines) gains an Authorization preflight test beside
the existing `Access-Control-Request-Headers: "Content-Type"` pin
(line 79). (d) **Settings knobs**: plain ints with explanatory
comments are the shape (`identity_submits_per_min: int = 3`
config.py:157; `sandbox_creates_per_min: int = 10` config.py:66) — the
D-088 throttle knobs land as Settings ints; the session TTL is a
MODULE constant, not a setting (the PAST_DUE_GRACE_DAYS shape,
pinned by test, no env entry). `.env.example` entries mirror the
AI_ADMIN_TOKEN block (lines 78-83, commented default-off).
**R-61. Web conversion census: what converts, what stays mock, what is
greenfield.** Employer pages at apps/web/app/(employer)/employer/:
`page.tsx` (overview) imports `candidates, employers` from
'@nextcraft/mock-data' (line 10) + inline METRICS/PIPELINE constants
(18-74) — converts to a session-gated client shell with real
active-postings count + per-posting applicant counts from the
applications table; talent-match/placement metrics STAY mock-labeled
(D-092 — no backend computes them). `postings/page.tsx` imports `jobs`
(line 2) → `<PostingManager jobs={jobs}>` (19); PostingManager
(components/employer/posting-manager.tsx) derives mock
statuses/applicants (29-37) — converts to own-postings CRUD/publish/
close. `talent/page.tsx` + `talent/[candidateId]/page.tsx` import
`candidates` — **STAY MOCK** per D-092 (no candidate backend exists in
any milestone). engine-client.ts (apps/web/lib/engine-client.ts — NOT
src/lib; 787 lines): section order sandboxes(171)/files(204)/
variants(255)/grading(281)/identity(295)/defense(348)/telemetry(413)/
lab-proctor(427)/marketplace(489)/search(603)/resume(674)/billing(717)/
billing-entitlements(726) — the employer section appends beside
billing. Error classes: EngineError/EngineBusyError/VerifyRequired
Error/NotAllowlistedError/RateLimitedError/UpgradeRequiredError/
NotFoundError — **no 401 discrimination exists today** (parseError
143-151 falls through to generic EngineError): the new
`SessionRequiredError` (D-092: 401 → honest logged-out state, never a
retry loop) joins the parseError chain. Credential-storage precedent:
`adminToken()/setAdminToken()` (759-767; sessionStorage
`'nc-admin-token'`, window-guarded) is the wrapper template for the
`'nc-employer-session'` key (logout clears storage + calls revoke;
AbortController on every fetch — the A-406 discipline). **No
login/register page exists in ANY route group** (admin auth is the
G-308 per-component token input, employer-entitlement-panel.tsx:60-72)
— /employer/login + /employer/register are greenfield client pages;
the entitlement panel's load/locked/error state machine (29-46,
75-80) is the closest fetch pattern. Wire types: new
packages/types/employer.ts + `export * from './employer'` in
index.ts (the billing.ts precedent); **PostingSummary is REUSED from
marketplace.ts:99-117, never duplicated**; the entitlement view
reuses the v0.9 shapes (engine-client.ts:730-752). Pin templates: the
billing.ts pin block (pins.test.ts:205-231) + the fetch-stub test
(billing-entitlement.test.ts:34-70) are what employer pins copy
(D-093). Pricing CTAs convert from contact-path to employer-login
path in pricing-client.tsx (superseding D-079; the "Contact Sales"
enterprise pin at pins.test.ts:250 stays).
**R-62. Admin network overview lands on the moderation page below the
v0.9 entitlement panel.** app/(admin)/admin/moderation/page.tsx
renders `<ModerationTabs />` (mock queues: employerVerificationQueue/
jobReviewQueue/flaggedContent — components/admin/moderation-tabs.tsx:
13-17) + the v0.9 section `<EmployerEntitlementPanel
employerId="emp-openai">` (page lines 15-23). D-094's network sections
land as sibling `<section>` blocks below: employer accounts list
(email, claimed entity, entitlement state columns), claim queue
(evidence + domain-match signal, approve/reject actions), audit —
fed by operator routes in api/employers_admin.py (D-094): GET
/v1/employers/admin/accounts, GET /v1/employers/admin/claims, POST
/v1/employers/admin/claims/{id}/approve, POST …/claims/{id}/reject,
GET /v1/employers/admin/audit — require_admin + employer_audit_events
appends (D-089). Data-fetch pattern to copy: the entitlement panel's
`load()` (token input → setAdminToken → fetch → **403 renders an
honest locked state with the token prompt, never a retry loop** —
employer-entitlement-panel.tsx:29-46, 75-80) via the existing
`adminToken()` G-308 wrapper.
**R-63. Both D-088 throttles copy proven in-tree deque/window
mechanics.** (a) Per-key window — `_SubmitRateLimiter`
(api/identity.py:97-116): module-level `dict[str, list[float]]`,
`time.monotonic()` timestamps, `window[:] = [t for t in window if
now - t < 60.0]` slice-trim, `len(window) >= per_min` → **429
"…rate exceeded — wait a minute"**, append AFTER the check; the
module-level singleton `_rate_limiter` (116) is accepted process-local
state (D-019: one process, one box). The per-EMAIL failed-login window
copies this keyed on the SUBMITTED EMAIL (D-088: never client IP —
behind HAProxy `request.client.host` is the proxy; zero IP-trust
machinery in-tree), with two deltas: failures-only append (a
successful login CLEARS the email's window) and a 15-minute window
(900s — identity hardcodes 60.0, so window seconds parameterize; the
cap is a Settings knob, the identity_submits_per_min shape). (b)
Global cap — `_check_global_create_rate` (api/sandboxes.py:123-137):
module-level `deque[float]` (`_CREATE_TIMES`, line 50), while-popleft
older-than-60s, `len >= settings.sandbox_creates_per_min` (config.py:
66) → **429 "…retry shortly"**, append only after ALL checks pass
(a rejected create never consumes budget); the D-088 global
login+register cap copies this deque verbatim. Third precedent for
dict-of-deques: `_APPLY_TIMES` (api/marketplace.py:58) +
`_check_apply_rate` (66-76) with module constant
`_APPLY_PER_LEARNER_PER_MIN = 5` (63). Test hygiene: module state
leaks across tests — BOTH employer limiters need the reset-fixture
pattern (`_reset_apply_rate_window`, tests/marketplace/conftest.py:
123-131, `_APPLY_TIMES.clear()`).
**R-64. tests/employers/ follows the tests/billing/ layout; the
employer_store fixture is state-injection.** tests/billing/ is 9 files
(test_api/test_base/test_entitlement_enforcement/test_factory/
test_lifecycle_admin/test_mock/test_store/test_stripe_provider/
test_webhook_route/test_webhooks) with NO billing/conftest.py —
fixtures are module-local `make_client` + tmp_path stores. The pattern
to copy (tests/billing/test_entitlement_enforcement.py:28-59): store
fixture (tmp_path db, yield, close), `make_client(tmp_path, store)`
builds `Settings(provider="mock", learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path/"app.db", sandbox_dir=tmp_path/"sandboxes",
admin_token=SecretStr(ADMIN_TOKEN))` → `create_app(settings)` →
**`app.state.billing_store = store` state-injection** (the G-9
override; main.py's getattr guard adopts it — also
tests/marketplace/conftest.py:80-120) → `with TestClient(app) as c`.
Root conftest.py supplies the session-scoped
`_hermetic_default_db_path` env redirect (58-80), the `settings`
fixture (169-183), SUITE_LEARNERS + seed_verified_identity (112-166);
the cloud-free MockProvider guard (205-213) stays satisfied — auth
has no external calls, zero network by construction. tests/employers/
adds: an `employer_store` fixture injected via
`app.state.employer_store`, throttle-window reset fixtures for both
limiters (R-63), REAL-scrypt auth flows (register+login ≈ 2 hashes ≈
100 ms/test — honest per D-096, never a fake-hash path; assertions are
accept/reject behavior only), claim→admin-approve→own-posting
lifecycle + cross-entity 403s, entitlement 402 arms via direct store
grants (the `grant_posts` precedent, test_entitlement_enforcement.py:
70-85), and the employer checkout E2E through the REAL webhook path
(the D-082 proof shape: mint_event → process_webhook_event). mypy
checks tests fully (pyproject files incl. "tests", 129-133) —
stdlib-only code needs no new overrides.
**R-65. Claim evidence: every seeded employer website is an
example.com placeholder — the match signal is honestly false
corpus-wide at pilot; normalization is stdlib suffix-match.**
EmployerRow carries `website: str` (marketplace/store.py:158,
sa_type=String) + `social_links: dict` (159, JSON); the public GET
/v1/marketplace/employers/{id} (api/marketplace.py:313-339) serves raw
+ merged-provenance fields. Seed corpus (marketplace/seed_corpus.json):
all employers' websites are `https://example.com/<slug>` (lines 13,
30, 47, 64, 81, 98, 115, 132, 149, 166, …) — a real registrant's
email domain will NOT match any seeded entity's website domain, so
`match` is false corpus-wide (D-089's advisory-display premise
confirmed: the admin queue DISPLAYS the signal, the operator decides —
auto-approval would be a dishonest gate, A-304). Normalization is
stdlib-only (zero-dep discipline): `urllib.parse.urlsplit(website
).hostname` (lowercases, strips scheme/path —
"https://example.com/openai" → "example.com"), email domain after the
last "@" lowercased; registrable-domain comparison = a simple SUFFIX
MATCH on the hostname (equal, or one endswith "." + other) — no PSL
library, no tldextract; a multi-label public suffix over-matches,
acceptable for an advisory signal and recorded in the module
docstring. Evidence shape per D-089: `{account_email_domain,
entity_website_domain, match}` stored as provenance JSON on the claim
row. Store enforcement: one PENDING claim per entity + one APPROVED
owner per entity, check-first (the insert_application precedent,
marketplace/store.py:341-347) + `employer_accounts.employer_id` UNIQUE
set on approval (rejection frees the entity for a new claim). Audit:
approve/reject set decided_by/decided_at + append
employer_audit_events (the BillingEventRow shape, billing/store.py:
337-359).
**R-66. Packaging/wiring: zero new dependencies, zero workspace
changes, two .env.example knobs.** scrypt/secrets/hmac/urllib are
stdlib — pyproject.toml:10-32 unchanged (requires-python ">=3.11");
the mypy zero-override regime holds (pyproject.toml:143-151 "currently
NONE" — D-086's passlib rejection stays valid: no new dep, no first
ignore_missing_imports). packages/types/employer.ts is a new file in
the EXISTING @nextcraft/types package + one index.ts export (the
billing.ts precedent); no package.json/pnpm-workspace/turbo changes
(no new apps/packages — D-031 extend pattern). .env.example gains the
D-088 throttle knobs beside the AI_ADMIN_TOKEN block (lines 78-83
pattern: commented, default-off, AI_ prefix per config.py:24); the
session TTL is a module constant (NO env entry — pinned by test).
Backend tree additions are exactly: ai_service/employers/, the two
api/employers*.py routers, api/employer postings/billing routes, the
D-091 api/ helper + its adoption in marketplace_admin.create_posting,
config.py knobs, main.py wiring (store + routers + CORS header),
tests/employers/, .env.example. Web additions: the engine-client
employer section, packages/types/employer.ts + pins, employer page
conversions + login/register pages, admin overview sections, pricing
CTA conversion.
## Open risks (v0.10, tracked into PLAN)
1. **scrypt CPU on concurrent auth** (R-55): ~50 ms/hash
single-threaded; a login burst stacks CPU. Bounded by both
throttle windows; acceptable at pilot — record the budget in the
module docstring.
2. **CORS Authorization omission kills every cross-origin employer
fetch** (E87 class, R-60): the main.py edit + the
tests/api/test_cors.py pin MUST land in the same phase as the
client section, not after.
3. **422 password echo** (R-56): the app-wide scrubber redacts only
date_of_birth (main.py:401) — a register 422 echoes the raw
password in `err["input"]`. Add "password" (+ "email") to
pii_fields in the auth phase; pin by test (security-auditor).
4. **sessionStorage token exposure** (D-092 tradeoff): XSS during a
live tab can read 'nc-employer-session'; tab-scoped by design,
documented in surface copy; logout + revoke deletes the server row
(stored hashed — a DB leak yields nothing).
5. **Employer mock-complete must complete one's OWN session**
(R-59): a provider_session_id belonging to another buyer is the
G-301 hole class — own-session check (403 on cross-session) +
provider=mock conditional mount + session gate, pinned by test.
6. **example.com seed websites** (R-65): claim evidence match=false
corpus-wide; the queue copy must present the signal as advisory
(A-304 — never display forgeable evidence as verification).
---
## v0.9 Phase 0 RESEARCH (Payment Engine — prior milestone, retained below)
Web-verified census of the payment/billing surface, 2026-09-29. Every
conclusion carries its source (docs.stripe.com page or in-tree file probe).
+18 -2
View File
@@ -2,7 +2,9 @@
## Overview
**Milestone v0.7 — Test Harness Review (COMPLETE, shipped as v0.6.6, 2026-09-27).** NFR refinement-only milestone reviewing the entire test harness: coverage/timeouts/markers wired, trivial tests cut with recorded triage, duplicated infra consolidated, 1,716 warnings/run eliminated to zero, ESLint+RUF100+mypy toolchain completed, recharts 3. Founder scope decisions D-048..D-051; clarify D-052..D-061; grill G-101..G-110. Tags on the v0.6.x line (v0.6.0 phase 0 → v0.6.6 final = milestone release); branch `milestone/v0.7-test-harness-review`.
**Milestone v0.10 — B2B Employer Network (ACTIVE, started 2026-09-30).** Feature milestone: real employer auth/accounts + self-serve (D-085). Employers become principals — registered accounts with sessions, employer-entity claim/verify, the employer dashboard converting from the v0.1 mockup to real data, self-serve posting management with the v0.9 entitlement enforcement intact, and self-serve checkout over the v0.9 billing catalog (superseding D-076 operator mediation; admin operator routes retained as fallback + oversight). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`.
**Milestone v0.9 — Payment Engine (COMPLETE, shipped as v0.8.6, 2026-09-30).** Payment processing and subscription management (REQ-F-018 activated per D-074): the billing module (provider protocol mock+stripe, 7th D-027 store, HMAC webhooks), catalog + pricing surface, operator-mediated checkout, entitlement enforcement, full subscription lifecycle. Founder scope decision D-074; clarify D-075..D-084; grill G-301+. Tags on the v0.8.x line (v0.8.0 phase 0 → v0.8.6 final = milestone release); branch `milestone/v0.9-payment-engine`.
**Milestone v0.6 — Marketplace Engine (COMPLETE, shipped as v0.5.6, 2026-09-26).** The real marketplace backend behind the existing surfaces: job posting backend + aggregation adapter (REQ-6-001), AI tagging (REQ-6-002), semantic vector search + match scoring (REQ-6-003), company enrichment + resume parsing (REQ-6-004/005), SEO programmatic pages (REQ-6-006). Scope decision D-047 (auto-decided at SPECIFY, full autonomy). Tags on the v0.5.x line (v0.5.0 phase 0 → v0.5.N final = milestone release); branch `milestone/v0.6-marketplace-engine`.
@@ -34,7 +36,21 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
## Phase List
### v0.9 (complete)
### v0.10 (active)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | active | — | — | Specify (D-085), clarify (D-086+), research (R-50+), plan, grill (G-401+), MVP/UX check complete; shipped as v0.9.0 |
| 1 | Employer auth foundation | pending | 0 | REQ-10-001 | `ai_service/employers/` module: account store (8th D-027 family) + hashed credentials + session tokens + register/login/logout/me routes + auth rate limiting; fail-closed secrets; tests never call a cloud |
| 2 | Employer entity claim | pending | 1 | REQ-10-002 | Claim route over existing EmployerRow entities; provenance-recorded claims; one account : one entity; admin claim review/approve/reject; claimed-entity authorization enforced |
| 3 | Employer account surface | pending | 2 | REQ-10-003 | Employer dashboard real-data conversion (mock imports → runtime fetches, A-406 pattern); login/register UI; session-gated shell; own-postings view; dual-schema wire pins |
| 4 | Self-serve posting management | pending | 3 | REQ-10-004 | Employer-principal posting routes with own-entity authorization; entitlement enforcement parameterized by session employer (402 CTA unchanged); admin routes retained |
| 5 | Self-serve checkout + admin overview | pending | 4 | REQ-10-005 | Employer-principal checkout over the v0.9 catalog; own entitlement view; pricing-page CTAs → employer-login path; admin network overview (accounts, claim queue, entitlement state) |
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit pass; milestone tagged v0.9.6 (final v0.9.x patch = milestone release); release with full asset matrix + registry image; milestone merged to main |
---
## Phase List (v0.9 — complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
+3 -3
View File
@@ -46,9 +46,9 @@
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.9",
"name": "payment-engine",
"version": "v0.10",
"name": "b2b-employer-network",
"type": "feature",
"branch": "milestone/v0.9-payment-engine"
"branch": "milestone/v0.10-b2b-employer-network"
}
}